yesod-core-1.7.0.0: test/Route/InlineParseClausesSpec.hs
{-# LANGUAGE CPP #-}
{-# LANGUAGE TemplateHaskellQuotes #-}
-- | Unit tests for 'buildInlineParseClauses', the pure (effect-free) core of
-- the backwards-compatible inline @parseRoute@ codegen. Because it is pure —
-- it takes a deterministic fresh-name supply instead of 'Q'\'s 'newName' and
-- builds AST directly rather than through quotation brackets — its '[Clause]'
-- output can be asserted on directly, with no splicing, compilation, or
-- 'runQ'. This is the testability win from extracting the pure builder out of
-- 'generateParseRouteClausesInline'.
module Route.InlineParseClausesSpec (spec) where
import Test.Hspec
import Data.Maybe (catMaybes)
import Language.Haskell.TH
import Web.PathPieces (fromPathPiece)
import Yesod.Routes.TH.Types
import Yesod.Routes.TH.ParseRoute (buildInlineParseClauses)
import Route.PureQ (runPureQ)
-- | Run the pure builder at the top level (no wrapping), the way
-- 'generateParseRouteClausesInline' invokes it.
run :: ResourceTree Type -> [Clause]
run t = runPureQ (buildInlineParseClauses id t)
leaf :: String -> [Piece Type] -> Dispatch Type -> ResourceTree Type
leaf name pieces d = ResourceLeaf (Resource name pieces d [] True)
methods :: [String] -> Dispatch Type
methods = Methods Nothing
parent :: String -> [Piece Type] -> [ResourceTree Type] -> ResourceTree Type
parent name pieces = ResourceParent name True mempty pieces
-- | Assert there is exactly one clause and hand it to the continuation.
withOnlyClause :: [Clause] -> (Clause -> Expectation) -> Expectation
withOnlyClause cs k =
case cs of
[c] -> k c
_ -> expectationFailure $ "expected exactly one clause, got " <> show (length cs)
-- | Variables bound by a @fromPathPiece@ view pattern, descending through the
-- list\/tuple\/constructor patterns the inline builder nests them in.
boundDynVars :: Pat -> [Name]
boundDynVars (ViewP (VarE f) inner)
| f == 'fromPathPiece = simpleVars inner
boundDynVars p = concatMap boundDynVars (subPats p)
-- | Plain 'VarP' names anywhere within a pattern.
simpleVars :: Pat -> [Name]
simpleVars (VarP n) = [n]
simpleVars p = concatMap simpleVars (subPats p)
-- | The immediate sub-patterns of a pattern (only the shapes the inline
-- builder produces need handling).
subPats :: Pat -> [Pat]
subPats (TupP ps) = ps
subPats (ListP ps) = ps
subPats (ViewP _ p) = [p]
subPats (ParensP p) = [p]
subPats (BangP p) = [p]
subPats (TildeP p) = [p]
subPats (AsP _ p) = [p]
subPats (SigP p _) = [p]
#if MIN_VERSION_template_haskell(2,18,0)
subPats (ConP _ _ ps) = ps
#else
subPats (ConP _ ps) = ps
#endif
subPats _ = []
-- | The variables referenced in an expression.
usedVars :: Exp -> [Name]
usedVars (VarE n) = [n]
usedVars e = concatMap usedVars (subExps e)
-- | The match alternatives of the top-level expression, when it is a @case@
-- (the shape a parent's committing clause produces). 'Nothing' otherwise.
caseMatches :: Exp -> Maybe [Match]
caseMatches (CaseE _ ms) = Just ms
caseMatches _ = Nothing
-- | The body expression of a clause that has no @where@ bindings.
clauseExp :: Clause -> Exp
clauseExp (Clause _ body _) = clauseBodyExp body
-- | Whether a match alternative is the catch-all @_ -> Nothing@ fallback.
isNothingFallback :: Match -> Bool
isNothingFallback (Match WildP (NormalB (ConE n)) []) = n == 'Nothing
isNothingFallback _ = False
-- | The immediate sub-expressions of an expression.
subExps :: Exp -> [Exp]
subExps (AppE a b) = [a, b]
subExps (AppTypeE e _) = [e]
subExps (InfixE ma _ mb) = catMaybes [ma, mb]
subExps (UInfixE a b c) = [a, b, c]
subExps (ParensE e) = [e]
subExps (LamE _ e) = [e]
subExps (SigE e _) = [e]
subExps (CondE a b c) = [a, b, c]
subExps (CaseE e ms) = e : [me | Match _ (NormalB me) _ <- ms]
subExps (ListE es) = es
#if MIN_VERSION_template_haskell(2,16,0)
subExps (TupE mes) = catMaybes mes
#else
subExps (TupE es) = es
#endif
subExps _ = []
-- | The expression in a clause body (the inline builder emits 'NormalB' for
-- leaves and a single-guard 'GuardedB' under fallthrough).
clauseBodyExp :: Body -> Exp
clauseBodyExp (NormalB e) = e
clauseBodyExp (GuardedB ((_, e) : _)) = e
-- Total on the (unreached) empty-guard case: a var-free sentinel so a stray
-- input fails an assertion locally instead of aborting the whole spec run.
clauseBodyExp (GuardedB []) = ConE '()
spec :: Spec
spec = describe "buildInlineParseClauses (pure inline parseRoute codegen)" $ do
it "produces one clause for a single static leaf" $
length (run (leaf "HomeR" [Static "home"] (methods ["GET"]))) `shouldBe` 1
it "emits exactly one (committing) clause per parent, matching its prefix once" $ do
-- 1.6 semantics: the parent contributes ONE top-level clause that
-- matches the parent prefix, then cases over its children. It does not
-- expand to one flat clause per descendant leaf (which would re-parse
-- the prefix and let a child miss fall through to a sibling route).
let tree = parent "AdminR" [Static "admin"]
[ leaf "UsersR" [Static "users"] (methods ["GET"])
, leaf "PostsR" [Static "posts"] (methods ["GET"])
]
withOnlyClause (run tree) $ \c ->
case caseMatches (clauseExp c) of
Nothing -> expectationFailure "expected the parent clause body to be a case over children"
-- two children + the committing Nothing fallback
Just ms -> length ms `shouldBe` 3
it "ends the parent's case with a Nothing fallback (commit-on-parent-prefix)" $ do
-- This is the heart of the backwards-compat fix: once the parent prefix
-- matches, a child miss must resolve to 'Nothing' (a 404 that agrees
-- with dispatch), NOT fall through to a later top-level route.
let tree = parent "AdminR" [Static "admin"]
[ leaf "UsersR" [Static "users"] (methods ["GET"]) ]
withOnlyClause (run tree) $ \c ->
case caseMatches (clauseExp c) of
Nothing -> expectationFailure "expected the parent clause body to be a case over children"
Just ms -> case reverse ms of
(lastM : _) -> isNothingFallback lastM `shouldBe` True
[] -> expectationFailure "expected at least the Nothing fallback"
it "commits at each level for deeply-nested parents (nested cases, each Nothing-terminated)" $ do
let tree = parent "AR" [Static "a"]
[ parent "BR" [Static "b"]
[ leaf "CR" [Static "c"] (methods ["GET"])
, leaf "DR" [Static "d"] (methods ["GET"])
]
]
-- One top-level clause for AR; its case has one child match (BR's
-- committing clause-as-match) plus the Nothing fallback.
withOnlyClause (run tree) $ \c ->
case caseMatches (clauseExp c) of
Nothing -> expectationFailure "expected AR's body to be a case"
Just msA -> do
length msA `shouldBe` 2
isNothingFallback (last msA) `shouldBe` True
-- The non-fallback alternative is BR's committing match,
-- whose body is itself a case (over CR/DR) ending in Nothing.
case msA of
(Match _ (NormalB innerE) [] : _) ->
case caseMatches innerE of
Nothing -> expectationFailure "expected BR's body to be a case"
Just msB -> do
length msB `shouldBe` 3 -- CR, DR, Nothing
isNothingFallback (last msB) `shouldBe` True
_ -> expectationFailure "unexpected shape for BR's match alternative"
it "binds a dynamic piece with a fromPathPiece view pattern" $
withOnlyClause (run (leaf "UserR" [Static "user", Dynamic (ConT ''Int)] (methods ["GET"]))) $ \c ->
pprint c `shouldContain` "fromPathPiece"
it "uses fromPathMultiPiece for a multipiece leaf" $
withOnlyClause (run (leaf "FilesR" [Static "files"] (Methods (Just (ConT ''String)) ["GET"]))) $ \c ->
pprint c `shouldContain` "fromPathMultiPiece"
it "delegates a subsite leaf through parseRoute" $
withOnlyClause (run (leaf "SubR" [Static "sub"] (Subsite (ConT (mkName "SubSite")) "getSub"))) $ \c ->
pprint c `shouldContain` "parseRoute"
it "prefixes accumulated parent pieces onto each descendant clause" $ do
-- The parent's static piece must appear in the (single) child clause,
-- alongside the child's own piece — that is what \"inline\" means.
let tree = parent "OrgR" [Static "org", Dynamic (ConT ''Int)]
[ leaf "TeamR" [Static "team"] (methods ["GET"]) ]
withOnlyClause (run tree) $ \c -> do
pprint c `shouldContain` "org"
pprint c `shouldContain` "team"
it "threads the parent's dynamic binder into the reconstructed route body" $ do
-- A parent with a dynamic piece: its binder must be matched in the path
-- pattern (via 'fromPathPiece') *and* fed back into the reconstructed
-- route in the body. That threading — not merely that the pieces show
-- up textually — is the property the inline arm has to preserve.
let tree = parent "OrgR" [Static "org", Dynamic (ConT ''Int)]
[ leaf "TeamR" [Static "team"] (methods ["GET"]) ]
withOnlyClause (run tree) $ \(Clause pats body _) -> do
let dynVars = concatMap boundDynVars pats
used = usedVars (clauseBodyExp body)
-- Exactly one dynamic piece (the parent's, bound via fromPathPiece)
-- and it is referenced in the reconstructed body — i.e. one 'True'.
-- Empty dynVars gives @[] /= [True]@, so this never passes vacuously.
map (`elem` used) dynVars `shouldBe` [True]