summaryrefslogtreecommitdiff
path: root/source/Felix/Test/Unit/HtmlLayout.hs
diff options
context:
space:
mode:
Diffstat (limited to 'source/Felix/Test/Unit/HtmlLayout.hs')
-rw-r--r--source/Felix/Test/Unit/HtmlLayout.hs478
1 files changed, 478 insertions, 0 deletions
diff --git a/source/Felix/Test/Unit/HtmlLayout.hs b/source/Felix/Test/Unit/HtmlLayout.hs
new file mode 100644
index 0000000..a3d701f
--- /dev/null
+++ b/source/Felix/Test/Unit/HtmlLayout.hs
@@ -0,0 +1,478 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module Felix.Test.Unit.HtmlLayout (unitTests) where
+
+import Base
+import Felix.Source
+import Felix.Source.Graph
+import Felix.Render.Html.Layout
+
+import Control.Exception (bracket)
+import Data.List qualified as List
+import Data.List.NonEmpty qualified as NonEmpty
+import Data.Text qualified as Text
+import System.Directory qualified as Directory
+import System.FilePath.Posix qualified as Posix
+import Test.Tasty
+import Test.Tasty.HUnit
+
+
+unitTests :: TestTree
+unitTests =
+ testGroup "HTML layout"
+ [ testCase
+ "encodes URL segments canonically"
+ encodesUrlSegments
+ , testCase
+ "renders relative typed URLs"
+ rendersRelativeUrls
+ , testCase
+ "separates mounted route namespaces"
+ separatesMountedNamespaces
+ , testCase
+ "routes searched and exact roots identically"
+ routesRootFormsIdentically
+ , testCase
+ "requires an explicit mount for an external root"
+ requiresExternalMount
+ , testCase
+ "reports URL and destination collisions independently"
+ reportsRouteCollisions
+ , testCase
+ "rejects ancestor and descendant destinations"
+ rejectsNestedDestinations
+ , testCase
+ "is independent of graph and configuration traversal order"
+ isTraversalOrderIndependent
+ ]
+
+
+encodesUrlSegments :: Assertion
+encodesUrlSegments = do
+ let accepted =
+ [ ("AZaz09-._~", "AZaz09-._~")
+ , ("#?% \\", "%23%3F%25%20%5C")
+ , ("über", "%C3%BCber")
+ , ("%2f", "%252f")
+ ]
+ for_ accepted \(decoded, expected) ->
+ assertEqual
+ ("encoded segment " <> Text.unpack decoded)
+ (Right expected)
+ (renderUrlSegment <$> urlSegment decoded)
+ let rejected =
+ [ ("", EmptyUrlSegment)
+ , (".", DotUrlSegment ".")
+ , ("..", DotUrlSegment "..")
+ , ("a/b", UrlSegmentContainsSeparator "a/b")
+ ]
+ for_ rejected \(decoded, expected) ->
+ assertEqual
+ ("rejected segment " <> Text.unpack decoded)
+ (Left expected)
+ (urlSegment decoded)
+ assertEqual
+ "fragment encoding"
+ "#name%23%C3%BC"
+ (renderUrlFragment "name#ü")
+
+rendersRelativeUrls :: Assertion
+rendersRelativeUrls = do
+ let cases =
+ [ ( ["library", "nested", "über"]
+ , ["_static", "naproche-html.js"]
+ , "../../_static/naproche-html.js"
+ )
+ , ( ["first", "entry"]
+ , ["second", "entry"]
+ , "../second/entry"
+ )
+ , ( ["mount", "entry"]
+ , ["mount", "a?b"]
+ , "a%3Fb"
+ )
+ ]
+ for_ cases \(currentSegments, targetSegments, expected) -> do
+ current <- expectRight (urlPath currentSegments)
+ target <- expectRight (urlPath targetSegments)
+ assertEqual
+ "relative URL"
+ expected
+ (renderRelativeUrlPath current target)
+ current <- expectRight (urlPath ["mount", "entry"])
+ target <- expectRight (urlPath ["mount", "ü"])
+ assertEqual
+ "encoded path and fragment remain separate"
+ "%C3%BC#part%23%3F"
+ ( renderRelativeUrlPath current target
+ <> renderUrlFragment "part#?"
+ )
+
+separatesMountedNamespaces :: Assertion
+separatesMountedNamespaces =
+ withTemporaryDirectory "felix-html-layout-mounts" \temp -> do
+ let mountSpecifications =
+ [ ("project", [])
+ , ("library", ["library"])
+ , ("debug", ["debug"])
+ , ("external", ["external"])
+ ]
+ roots <- for mountSpecifications \(ident, _prefix) -> do
+ let root = temp Posix.</> Text.unpack ident
+ Directory.createDirectory root
+ writeTheory (root Posix.</> "entry.tex") []
+ pure (sourceMountId ident, root)
+ mounts <- expectRight =<< prepareSourceMounts roots
+ routes <- for mountSpecifications \(ident, prefix) -> do
+ let sourcePath =
+ temp
+ Posix.</> Text.unpack ident
+ Posix.</> "entry.tex"
+ request <- expectRight =<< existingRoot sourcePath
+ graph <- expectRight =<< buildResolvedSourceGraph mounts request
+ layout <-
+ expectRight
+ (layoutHtmlSourceGraph
+ [ (sourceMountId configuredId, configuredPrefix)
+ | (configuredId, configuredPrefix) <-
+ mountSpecifications
+ ]
+ graph)
+ route <- requireRootRoute graph layout
+ pure
+ ( ident
+ , renderUrlPath (routeUrlPath route)
+ , safeRelativePathFilePath
+ (routeDestination route)
+ , prefix
+ )
+ assertEqual
+ "mount URLs"
+ [ ("project", "/entry")
+ , ("library", "/library/entry")
+ , ("debug", "/debug/entry")
+ , ("external", "/external/entry")
+ ]
+ [ (ident, url) | (ident, url, _destination, _prefix) <- routes ]
+ assertEqual
+ "mount destinations"
+ [ ("project", "entry.html")
+ , ("library", "library/entry.html")
+ , ("debug", "debug/entry.html")
+ , ("external", "external/entry.html")
+ ]
+ [ (ident, destination)
+ | (ident, _url, destination, _prefix) <- routes
+ ]
+
+routesRootFormsIdentically :: Assertion
+routesRootFormsIdentically =
+ withTemporaryDirectory "felix-html-layout-root-forms" \temp -> do
+ let libraryRoot = temp Posix.</> "library"
+ entry = libraryRoot Posix.</> "entry.tex"
+ Directory.createDirectory libraryRoot
+ writeTheory entry []
+ mounts <- expectRight =<< prepareSourceMounts
+ [ (sourceMountId "project", temp)
+ , (sourceMountId "library", libraryRoot)
+ ]
+ searched <- expectRight (searchedRoot "library/entry.tex")
+ exact <- expectRight =<< existingRoot entry
+ searchedGraph <-
+ expectRight =<< buildResolvedSourceGraph mounts searched
+ exactGraph <-
+ expectRight =<< buildResolvedSourceGraph mounts exact
+ let configuration =
+ [ (sourceMountId "project", [])
+ , (sourceMountId "library", ["library"])
+ ]
+ searchedLayout <-
+ expectRight
+ (layoutHtmlSourceGraph configuration searchedGraph)
+ exactLayout <-
+ expectRight
+ (layoutHtmlSourceGraph configuration exactGraph)
+ searchedRoute <-
+ requireRootRoute searchedGraph searchedLayout
+ exactRoute <-
+ requireRootRoute exactGraph exactLayout
+ assertEqual "selected route" searchedRoute exactRoute
+ assertEqual
+ "most-specific URL"
+ "/library/entry"
+ (renderUrlPath (routeUrlPath searchedRoute))
+
+requiresExternalMount :: Assertion
+requiresExternalMount =
+ withTemporaryDirectory "felix-html-layout-external" \temp -> do
+ let projectRoot = temp Posix.</> "project"
+ externalRoot = temp Posix.</> "vendor"
+ externalEntry = externalRoot Posix.</> "entry.tex"
+ Directory.createDirectory projectRoot
+ Directory.createDirectory externalRoot
+ writeTheory externalEntry []
+ request <- expectRight =<< existingRoot externalEntry
+ projectMounts <- expectRight =<< prepareSourceMounts
+ [(sourceMountId "project", projectRoot)]
+ outsideResult <-
+ buildResolvedSourceGraph projectMounts request
+ case outsideResult of
+ Left RootOutsideConfiguredMount{} ->
+ pure ()
+ result ->
+ assertFailure
+ ("expected external root rejection, got "
+ <> show result)
+ mounted <- expectRight =<< prepareSourceMounts
+ [ (sourceMountId "project", projectRoot)
+ , (sourceMountId "external", externalRoot)
+ ]
+ graph <- expectRight =<< buildResolvedSourceGraph mounted request
+ layout <-
+ expectRight
+ (layoutHtmlSourceGraph
+ [ (sourceMountId "project", [])
+ , (sourceMountId "external", ["vendor"])
+ ]
+ graph)
+ route <- requireRootRoute graph layout
+ assertEqual
+ "external URL"
+ "/vendor/entry"
+ (renderUrlPath (routeUrlPath route))
+
+reportsRouteCollisions :: Assertion
+reportsRouteCollisions = do
+ reportsPageCollisions
+ reportsAssetUrlCollision
+
+reportsPageCollisions :: Assertion
+reportsPageCollisions =
+ withTemporaryDirectory "felix-html-layout-page-collision" \temp -> do
+ let firstRoot = temp Posix.</> "first"
+ secondRoot = temp Posix.</> "second"
+ firstEntry = firstRoot Posix.</> "two" Posix.</> "a.tex"
+ Directory.createDirectory firstRoot
+ Directory.createDirectory secondRoot
+ Directory.createDirectory (firstRoot Posix.</> "two")
+ writeTheory (secondRoot Posix.</> "a.tex") []
+ writeTheory firstEntry ["a.tex"]
+ mounts <- expectRight =<< prepareSourceMounts
+ [ (sourceMountId "first", firstRoot)
+ , (sourceMountId "second", secondRoot)
+ ]
+ request <- expectRight =<< existingRoot firstEntry
+ graph <- expectRight =<< buildResolvedSourceGraph mounts request
+ let configuration =
+ [ (sourceMountId "first", ["one"])
+ , (sourceMountId "second", ["one", "two"])
+ ]
+ case layoutHtmlSourceGraph configuration graph of
+ Left
+ (CollidingHtmlRoutes
+ [HtmlUrlRouteCollision url owners]
+ [HtmlDestinationRouteCollision destination
+ destinationOwners]) -> do
+ assertEqual
+ "canonical URL collision"
+ "/one/two/a"
+ (renderUrlPath url)
+ assertEqual
+ "URL owners"
+ (NonEmpty.toList owners)
+ (NonEmpty.toList destinationOwners)
+ assertEqual
+ "destination collision"
+ "one/two/a.html"
+ (safeRelativePathFilePath destination)
+ result ->
+ assertFailure
+ ("expected paired route collisions, got "
+ <> show result)
+
+reportsAssetUrlCollision :: Assertion
+reportsAssetUrlCollision =
+ withTemporaryDirectory "felix-html-layout-asset-collision" \temp -> do
+ let entry =
+ temp
+ Posix.</> "_static"
+ Posix.</> "naproche-html.js.tex"
+ Directory.createDirectory (temp Posix.</> "_static")
+ writeTheory entry []
+ mounts <- expectRight =<< prepareSourceMounts
+ [(sourceMountId "project", temp)]
+ request <-
+ expectRight
+ (searchedRoot "_static/naproche-html.js.tex")
+ graph <- expectRight =<< buildResolvedSourceGraph mounts request
+ case
+ layoutHtmlSourceGraph
+ [(sourceMountId "project", [])]
+ graph of
+ Left
+ (CollidingHtmlRoutes
+ [HtmlUrlRouteCollision url _owners]
+ []) ->
+ assertEqual
+ "page/asset URL collision"
+ "/_static/naproche-html.js"
+ (renderUrlPath url)
+ result ->
+ assertFailure
+ ("expected URL-only asset collision, got "
+ <> show result)
+
+rejectsNestedDestinations :: Assertion
+rejectsNestedDestinations =
+ withTemporaryDirectory "felix-html-layout-nested" \temp -> do
+ let nestedDirectory = temp Posix.</> "a.html"
+ Directory.createDirectory nestedDirectory
+ writeTheory (temp Posix.</> "a.tex") []
+ writeTheory (nestedDirectory Posix.</> "b.tex") []
+ writeTheory
+ (temp Posix.</> "entry.tex")
+ ["a.tex", "a.html/b.tex"]
+ mounts <- expectRight =<< prepareSourceMounts
+ [(sourceMountId "project", temp)]
+ request <- expectRight (searchedRoot "entry.tex")
+ graph <- expectRight =<< buildResolvedSourceGraph mounts request
+ case layoutHtmlSourceGraph
+ [(sourceMountId "project", [])]
+ graph of
+ Left
+ (CollidingHtmlRoutes
+ []
+ [NestedHtmlDestinationRouteCollision
+ ancestor
+ ancestorOwner
+ descendant
+ descendantOwner]) -> do
+ assertEqual
+ "ancestor destination"
+ "a.html"
+ (safeRelativePathFilePath ancestor)
+ assertEqual
+ "ancestor owner"
+ "a.tex"
+ (pageOwnerPath ancestorOwner)
+ assertEqual
+ "descendant destination"
+ "a.html/b.html"
+ (safeRelativePathFilePath descendant)
+ assertEqual
+ "descendant owner"
+ "a.html/b.tex"
+ (pageOwnerPath descendantOwner)
+ result ->
+ assertFailure
+ ("expected nested destination collision, got "
+ <> show result)
+ where
+ pageOwnerPath = \case
+ HtmlPage source ->
+ safeRelativePathFilePath
+ (resolvedSourceRelativePath source)
+ HtmlSupportScript ->
+ "<support script>"
+
+isTraversalOrderIndependent :: Assertion
+isTraversalOrderIndependent =
+ withTemporaryDirectory "felix-html-layout-order" \temp -> do
+ writeTheory (temp Posix.</> "a.tex") []
+ writeTheory (temp Posix.</> "b.tex") []
+ let root = temp Posix.</> "entry.tex"
+ writeTheory root ["a.tex", "b.tex"]
+ mounts <- expectRight =<< prepareSourceMounts
+ [(sourceMountId "project", temp)]
+ request <- expectRight (searchedRoot "entry.tex")
+ firstGraph <-
+ expectRight =<< buildResolvedSourceGraph mounts request
+ writeTheory root ["b.tex", "a.tex"]
+ secondGraph <-
+ expectRight =<< buildResolvedSourceGraph mounts request
+ let firstConfiguration =
+ [ (sourceMountId "unused", ["unused"])
+ , (sourceMountId "project", [])
+ ]
+ firstLayout <-
+ expectRight
+ (layoutHtmlSourceGraph
+ firstConfiguration
+ firstGraph)
+ for_
+ (zip
+ (cycle [firstGraph, secondGraph])
+ (List.permutations firstConfiguration))
+ \(orderedGraph, configuration) -> do
+ layout <-
+ expectRight
+ (layoutHtmlSourceGraph
+ configuration
+ orderedGraph)
+ assertEqual
+ "route table"
+ firstLayout
+ layout
+
+ let collidingConfiguration =
+ [ (sourceMountId "z", ["same"])
+ , (sourceMountId "a", ["same"])
+ , (sourceMountId "project", [])
+ ]
+ expectedCollision =
+ layoutHtmlSourceGraph
+ collidingConfiguration
+ firstGraph
+ for_
+ (zip
+ (cycle [firstGraph, secondGraph])
+ (List.permutations collidingConfiguration))
+ \(orderedGraph, configuration) ->
+ assertEqual
+ "collision diagnostic"
+ expectedCollision
+ (layoutHtmlSourceGraph
+ configuration
+ orderedGraph)
+
+
+requireRootRoute
+ :: ResolvedSourceGraph
+ -> HtmlLayout
+ -> IO HtmlRoute
+requireRootRoute graph layout =
+ case htmlPageRoute layout (sourceGraphRootSource graph) of
+ Just route ->
+ pure route
+ Nothing -> do
+ assertFailure "layout omitted the root source"
+ pure (impossible "requireRootRoute: assertFailure returned")
+
+writeTheory :: FilePath -> [FilePath] -> IO ()
+writeTheory path imports =
+ writeFile path
+ (unlines
+ (["\\import{" <> imported <> "}" | imported <- imports]
+ <> [ "\\begin{axiom}\\label{route_fixture}"
+ , " $x = x$."
+ , "\\end{axiom}"
+ ]))
+
+expectRight :: (Show e, HasCallStack) => Either e a -> IO a
+expectRight = \case
+ Left err ->
+ assertFailure ("expected Right, got Left " <> show err)
+ Right value ->
+ pure value
+
+withTemporaryDirectory :: String -> (FilePath -> IO a) -> IO a
+withTemporaryDirectory template =
+ bracket create Directory.removePathForcibly
+ where
+ create = do
+ systemTemp <- Directory.getTemporaryDirectory
+ (path, handle) <- openTempFile systemTemp template
+ hClose handle
+ Directory.removeFile path
+ Directory.createDirectory path
+ pure path