djot-0.1.4.3: test/Main.hs
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
import Test.Tasty
import Test.Tasty.QuickCheck
import Test.Tasty.HUnit
import qualified Data.Text.Lazy as TL
import Data.Text.Lazy.Encoding (decodeUtf8With, encodeUtf8)
import Data.Text.Encoding.Error (lenientDecode)
import qualified Data.ByteString as B
import qualified Data.ByteString.Lazy.Char8 as BL
import Data.ByteString.Builder ( toLazyByteString )
import Djot ( ParseOptions(..), RenderOptions(..), SourcePosOption(..),
parseDoc, renderHtml, renderDjot )
import Djot.Parse ( parse, satisfy, strToUtf8, utf8ToStr, Chunk(..) )
import Djot.AST
import System.FilePath ((</>), takeExtension, takeFileName)
import System.Directory (getDirectoryContents)
import Text.DocLayout (render)
main :: IO ()
main = do
specTests <- filter ((== ".test") . takeExtension) <$>
getDirectoryContents "test"
tests <- mapM (\fp -> (fp,) <$> getSpecTests ("test" </> fp)) specTests
let parser = parseDoc ParseOptions{ sourcePositions = NoSourcePos } . BL.toStrict
defaultMain $ testGroup "Tests" $
[ testGroup "djot -> html"
(map (\(fp, ts) ->
testGroup fp
(map (toSpecTest parser) ts)) tests)
, testGroup "native -> djot -> native"
[testGroup fp (map (toRoundTripTest parser) ts)
| (fp, ts) <- tests
, takeFileName fp /= "raw.test"]
, testGroup "Djot.Parse" parserTests
, testGroup "Djot.AST" astTests
, testGroup "djot writer" writerTests
, testGroup "sourcepos" sourcePosTests
, testGroup "Fuzz"
[testProperty "parses all inputs"
(\s -> case parseDoc ParseOptions{ sourcePositions = NoSourcePos }
(strToUtf8 s) of
Left _ -> False
Right _ -> True)
]
]
parserTests :: [TestTree]
parserTests =
[ testCase "satisfy multibyte"
(parse (satisfy (=='ǎ') *> satisfy (=='老')) ()
(toChunks $ strToUtf8 "ǎ老bc") @?= Just '老')
, testProperty "UTF8 conversion round-trips"
(\s -> utf8ToStr (strToUtf8 s) == s)
, testCase "empty chunk mid-stream does not cause premature EOF"
(parse (satisfy (=='a') *> satisfy (=='b')) ()
[ Chunk{ chunkBytes = "a", chunkLine = 1, chunkColumn = 0 }
, Chunk{ chunkBytes = "", chunkLine = 2, chunkColumn = 0 }
, Chunk{ chunkBytes = "b", chunkLine = 3, chunkColumn = 0 }
] @?= Just 'b')
, testCase "leading empty chunk does not cause premature EOF"
(parse (satisfy (=='a')) ()
[ Chunk{ chunkBytes = "", chunkLine = 1, chunkColumn = 0 }
, Chunk{ chunkBytes = "a", chunkLine = 2, chunkColumn = 0 }
] @?= Just 'a')
]
astTests :: [TestTree]
astTests =
[ testCase "NoPos is an identity for <>" $ do
Pos 1 1 2 5 <> NoPos @?= Pos 1 1 2 5
NoPos <> Pos 1 1 2 5 @?= Pos 1 1 2 5
, testCase "<> on Pos spans both arguments" $
Pos 1 1 1 4 <> Pos 2 1 2 7 @?= Pos 1 1 2 7
, testCase "inlinesToByteString emits valid UTF-8" $ do
inlinesToByteString (singleQuoted (str "a")) @?=
strToUtf8 "\x2018\&a\x2019"
inlinesToByteString (doubleQuoted (str "a")) @?=
strToUtf8 "\x201C\&a\x201D"
inlinesToByteString nonBreakingSpace @?= strToUtf8 "\xA0"
, testCase "image alt text with smart quotes is valid UTF-8" $
convertNoPos "\n" @?=
"<p><img alt=\"a \x201C\&b\x201D c\" src=\"url\"></p>\n"
, testCase "auto identifier with smart quotes is valid UTF-8" $
convertNoPos "# Say \"hi\"\n" @?=
"<section id=\"Say-\x201Chi\x201D\">\n<h1>Say \x201Chi\x201D</h1>\n</section>\n"
, testCase "autolink with dot before @ is an email link" $
convertNoPos "<user.name@example.com>\n" @?=
"<p><a href=\"mailto:user.name@example.com\">user.name@example.com</a></p>\n"
, testCase "autolink with @ only after : is a url link" $
convertNoPos "<x:@example.com>\n" @?=
"<p><a href=\"x:@example.com\">x:@example.com</a></p>\n"
, testCase "CR-only line ending produces a soft break" $
convertNoPos "a\rb\n" @?= "<p>a\nb</p>\n"
, testCase "CRLF line ending produces a soft break" $
convertNoPos "a\r\nb\n" @?= "<p>a\nb</p>\n"
, testCase "whitespace runs in quoted attribute values collapse (as in djot.js)" $
convertNoPos "{k=\"a b\"}\npara\n" @?=
"<p k=\"a b\">para</p>\n"
, testCase "newline in quoted attribute value becomes a space" $
convertNoPos "{k=\"a\n b\"}\npara\n" @?=
"<p k=\"a b\">para</p>\n"
, testCase "reference labels of no more than 400 bytes" $ do
let label400 = BL.pack (replicate 400 'x')
convertNoPos ("[a][" <> label400 <> "]\n") @?= "<p><a>a</a></p>\n"
convertNoPos ("[a][" <> label400 <> "y]\n") @?=
"<p>[a][" <> fromUtf8 (label400 <> "y") <> "]</p>\n"
]
convertNoPos :: BL.ByteString -> TL.Text
convertNoPos = either mempty (fromUtf8 . toLazyByteString .
renderHtml RenderOptions{ preserveSoftBreaks = True })
. parseDoc ParseOptions{ sourcePositions = NoSourcePos }
. BL.toStrict
writerTests :: [TestTree]
writerTests =
[ testCase "letter list style wraps around after 26" $
render Nothing (renderDjot RenderOptions{ preserveSoftBreaks = True }
mempty{ docBlocks = Djot.AST.orderedList
OrderedListAttributes{ orderedListStyle = LetterUpper
, orderedListDelim = RightPeriod
, orderedListStart = 27 }
Tight
[para (str "one"), para (str "two")] })
@?= "A. one\nB. two\n"
]
sourcePosTests :: [TestTree]
sourcePosTests =
let convert = either mempty (fromUtf8 . toLazyByteString .
renderHtml RenderOptions{ preserveSoftBreaks = True })
. parseDoc ParseOptions{ sourcePositions = AllSourcePos }
in [ testCase "period at end" $
convert "the `goo` option.\n" @?=
"<p data-pos=\"1:1-1:17\"><span data-pos=\"1:1-1:4\">the </span><code data-pos=\"1:5-1:9\">goo</code><span data-pos=\"1:10-1:17\"> option.</span></p>\n"
, testCase "attr after *" $
convert "*{.foo}\n" @?=
"<p data-pos=\"1:1-1:7\"><span data-pos=\"1:1-1:1\" class=\"foo\">*</span></p>\n"
, testCase "attr on last word of merged strs" $
convert "x y.z{.c}\n" @?=
"<p data-pos=\"1:1-1:9\"><span data-pos=\"1:1-1:2\">x </span><span data-pos=\"1:3-1:5\" class=\"c\">y.z</span></p>\n"
, testCase "tab advances to next tab stop (1-based)" $
convert "a\tb *c*\n" @?=
"<p data-pos=\"1:1-1:9\"><span data-pos=\"1:1-1:6\">a\tb </span><strong data-pos=\"1:7-1:9\"><span data-pos=\"1:8-1:8\">c</span></strong></p>\n"
, testCase "no newline at end" $
convert "foo" @?=
"<p data-pos=\"1:1-1:3\"><span data-pos=\"1:1-1:3\">foo</span></p>\n"
, testCase "list" $
convert "1. > hello\nthere\n\n2. ok" @?=
"<ol data-pos=\"1:1-4:6\">\n<li>\n<blockquote data-pos=\"1:4-2:5\">\n<p data-pos=\"1:6-2:5\"><span data-pos=\"1:6-1:10\">hello</span>\n<span data-pos=\"2:1-2:5\">there</span></p>\n</blockquote>\n</li>\n<li>\n<p data-pos=\"4:5-4:6\"><span data-pos=\"4:5-4:6\">ok</span></p>\n</li>\n</ol>\n"
, testCase "code block" $
convert "``` ruby\nhi\n```\n" @?=
"<pre data-pos=\"1:1-3:3\"><code class=\"language-ruby\">hi\n</code></pre>\n"
, testCase "nested " $
convert "*_hi_*" @?=
"<p data-pos=\"1:1-1:6\"><strong data-pos=\"1:1-1:6\"><em data-pos=\"1:2-1:5\"><span data-pos=\"1:3-1:4\">hi</span></em></strong></p>\n"
, testCase "hr " $
convert "----\n" @?=
"<hr data-pos=\"1:1-1:4\">\n"
]
toChunks :: B.ByteString -> [Chunk]
toChunks bs = [Chunk{ chunkBytes = bs, chunkLine = 1, chunkColumn = 0 }]
toSpecTest :: (BL.ByteString -> Either String Doc)
-> SpecTest -> TestTree
toSpecTest parser st =
testCase name (actual @?= expected)
where name = "lines " ++ show (start_line st) ++ "-" ++ show (end_line st)
expected = fromUtf8 $ html st
ropts = RenderOptions{ preserveSoftBreaks = True }
actual = either mempty (fromUtf8 . toLazyByteString . renderHtml ropts)
. parser $ djot st
toRoundTripTest :: (BL.ByteString -> Either String Doc)
-> SpecTest -> TestTree
toRoundTripTest parser st =
testCase name ((actual == expected) @? rtlog)
where name = "lines " ++ show (start_line st) ++ "-" ++ show (end_line st)
native = either (\_ -> mempty) id $ parser (djot st)
expected = native
ropts = RenderOptions{ preserveSoftBreaks = True }
renderedDjot = encodeUtf8 . TL.fromStrict $ render (Just 78) $
renderDjot ropts native
actual = either (\_ -> mempty) id $ parser renderedDjot
lbsToStr = TL.unpack . fromUtf8
rtlog = lbsToStr (djot st) <>
"↓\n" <>
show native <> "\n" <>
"↓\n" <>
lbsToStr renderedDjot <>
"↓\n" <>
show actual <> "\n"
data SpecTest = SpecTest
{ djot :: BL.ByteString
, source :: FilePath
, end_line :: Int
, start_line :: Int
, html :: BL.ByteString }
deriving (Show)
getSpecTests :: FilePath -> IO [SpecTest]
getSpecTests fp = do
speclines <- zip [1..] . BL.lines <$> BL.readFile fp
pure $ parseSpecTests fp speclines
--- state machine parser for spec test cases
data ParseState =
Scanning
| ParsingDjot (SpecTest, BL.ByteString)
| ParsingHtml (SpecTest, BL.ByteString)
deriving (Show)
parseSpecTests :: FilePath -> [(Int, BL.ByteString)] -> [SpecTest]
parseSpecTests fp = go Scanning
where
go _ [] = []
go Scanning ((ln, bs) : xs)
| BL.length bs > 0 && BL.all (== '`') bs =
go (ParsingDjot (SpecTest { djot = mempty
, source = fp
, end_line = ln
, start_line = ln
, html = mempty }, bs)) xs
| otherwise = go Scanning xs
go (ParsingDjot (st,fence)) ((_,bs) : xs)
| bs == "." =
go (ParsingHtml (st, fence)) xs
| otherwise =
go (ParsingDjot (st{ djot = djot st <> bs <> "\n" }, fence)) xs
go (ParsingHtml (st,fence)) ((ln,bs) : xs)
| bs == fence =
st{ end_line = ln } : go Scanning xs
| otherwise =
go (ParsingHtml (st{ html = html st <> bs <> "\n" }, fence)) xs
fromUtf8 :: BL.ByteString -> TL.Text
fromUtf8 = decodeUtf8With lenientDecode