psb/tests/Test/Gen.hs
Pagwin 628f01217e
restructure the tests and assert on whole trees
The suite is now one tasty binary over shared modules, so the Djot
tests can sit next to the Markdown ones without another copy of the
harness:

  tests/Main.hs             the TestTree
  tests/Test/Harness.hs     running a parser inside a property
  tests/Test/Gen.hs         generators, shrinkers, escaping
  tests/Test/Gen/Document.hs  whole document generation
  tests/Markdown/Parse.hs   the Markdown properties

Generated values moved from `pick` to `Arbitrary` instances on
newtypes. `pick` embeds its value with `forAll (return a)` and cannot
shrink; going through Arbitrary gets shrinking back at the cost of
hand written shrinkers, which have to preserve the invariants their
generator established.

IR now derives Eq, and the properties assert against a complete
expected tree rather than pattern matching. The old patterns bound
fresh names that shadowed the generated ones, so they only ever
checked the shape of the tree and never its content.

Test.Gen.Document generates a description of a document which renders
to source and derives its own expected tree, so the two stay in step
while shrinking. Rendering goes through a Syntax record so Djot can
reuse the spec and the expectations. Escaping is a generator level
concern: an Escaper says which characters are special and how (or
whether) they can be written literally.

Two properties fail, both on parser bugs rather than test bugs:

  code_block          fencedCodeBlock inverts its language test, so a
                      fence naming a language reports Nothing. Djot.hs
                      gets this right at src/Djot.hs:354.
  document_round_trip a nested list with more than one item makes the
                      whole list fail to parse and fall back to a
                      paragraph. In listBlock's child parser the second
                      alternative has no `try`, so it fails having
                      consumed the first item's indentation. The
                      existing nesting tests only ever used one nested
                      item.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 16:15:33 -04:00

179 lines
6.6 KiB
Haskell

{-# LANGUAGE OverloadedStrings #-}
-- | Generators shared by the parser test suites.
--
-- Everything a property generates lives behind a newtype with an `Arbitrary`
-- instance rather than being pulled in with `pick`. `pick` embeds its value
-- with `forAll (return a)`, which cannot shrink, so a failing property would
-- report whatever random 10 character string happened to trip it. Going
-- through `Arbitrary` gets shrinking back, at the cost of having to write the
-- shrinkers by hand.
--
-- Those shrinkers have to preserve the invariants their generator established
-- (non-empty, alphabet, ranges). A shrunk counterexample that no longer
-- satisfies what the property assumes is worse than no shrinking at all, it
-- reports a failure for an input the test was never making a claim about.
module Test.Gen
( -- * sized primitives
linear,
textOf,
asciiChar,
alphaChar,
alphaNumChar,
wordsOf,
-- * shrinking
shrinkTextWith,
-- * wrappers
AsciiText (..),
AlphaText (..),
AlphaNumText (..),
HeaderLevel (..),
-- * escaping
Escaper (..),
backslashEscaper,
unescapable,
markdownEscaper,
escapeWith,
EscapedText (..),
escapedText,
MarkdownText (..),
)
where
import Data.Text (Text)
import qualified Data.Text as T
import Test.QuickCheck (Arbitrary (arbitrary, shrink), Gen, chooseInt, elements, frequency, sized, suchThat, vectorOf)
-- | Hedgehog's `Range.linear` grows its upper bound with the size of the test
-- case, this is the same thing in terms of QuickCheck's size parameter.
linear :: Int -> Int -> Gen Int
linear lo hi = sized $ \size -> chooseInt (lo, lo + ((hi - lo) * min size 99) `div` 99)
textOf :: Gen Char -> Int -> Int -> Gen Text
textOf char_gen lo hi = do
len <- linear lo hi
T.pack <$> vectorOf len char_gen
asciiChar :: Gen Char
asciiChar = elements ['\0' .. '\127']
alphaChar :: Gen Char
alphaChar = elements (['a' .. 'z'] <> ['A' .. 'Z'])
alphaNumChar :: Gen Char
alphaNumChar = elements (['a' .. 'z'] <> ['A' .. 'Z'] <> ['0' .. '9'])
-- | One to three alphanumeric words separated by single spaces. Never starts or
-- ends with a space, so it can sit directly after a block marker without the
-- marker's own trailing space being ambiguous.
wordsOf :: Gen Text
wordsOf = do
count <- chooseInt (1, 3)
T.unwords <$> vectorOf count (textOf alphaNumChar 1 8)
-- | Shrink by taking shorter prefixes, dropping any that no longer satisfy the
-- predicate the generator guaranteed. Prefixes keep the first character, which
-- is what stops a shrunk value from acquiring a leading space or a digit where
-- the original had a letter.
shrinkTextWith :: (Text -> Bool) -> Text -> [Text]
shrinkTextWith valid t = filter valid [T.take n t | n <- [1 .. T.length t - 1]]
newtype AsciiText = AsciiText Text
deriving (Show)
instance Arbitrary AsciiText where
arbitrary = AsciiText <$> textOf asciiChar 0 100
shrink (AsciiText t) = AsciiText <$> shrinkTextWith (const True) t
newtype AlphaText = AlphaText Text
deriving (Show)
instance Arbitrary AlphaText where
arbitrary = AlphaText <$> textOf alphaChar 1 10
shrink (AlphaText t) = AlphaText <$> shrinkTextWith (not . T.null) t
newtype AlphaNumText = AlphaNumText Text
deriving (Show)
instance Arbitrary AlphaNumText where
arbitrary = AlphaNumText <$> textOf alphaNumChar 1 10
shrink (AlphaNumText t) = AlphaNumText <$> shrinkTextWith (not . T.null) t
newtype HeaderLevel = HeaderLevel Int
deriving (Show)
instance Arbitrary HeaderLevel where
arbitrary = HeaderLevel <$> chooseInt (1, 6)
shrink (HeaderLevel level) = HeaderLevel <$> [1 .. level - 1]
-- | How a syntax lets you write a character that would otherwise be markup.
--
-- `escapeChar` is Nothing for a syntax with no escape mechanism at all, in
-- which case the only way to get a special character into plain text is not to
-- generate one. Markdown is currently in that boat.
data Escaper = Escaper
{ specialChars :: [Char],
escapeChar :: Maybe (Char -> Text)
}
backslashEscaper :: [Char] -> Escaper
backslashEscaper cs = Escaper {specialChars = cs, escapeChar = Just $ \c -> T.pack ['\\', c]}
unescapable :: [Char] -> Escaper
unescapable cs = Escaper {specialChars = cs, escapeChar = Nothing}
-- | The characters that start markup in the Markdown parser's inline layer.
-- \`*[~ are the ones `plain_text` breaks a text node on, _ starts an underline
-- and ! an image, both of which are only recognised at the start of an inline
-- run. Markdown.hs has no backslash escape handling, so this is `unescapable`.
markdownEscaper :: Escaper
markdownEscaper = unescapable "`*[~_!<"
-- | A piece of source text paired with the literal it is supposed to parse to.
-- For an escaping syntax these differ, for `unescapable` they are equal because
-- the only representable literals are the ones needing no escape.
data EscapedText = EscapedText
{ rendered :: Text,
literal :: Text
}
deriving (Show)
-- | Write a literal out in a form the syntax will read back as that literal.
escapeWith :: Escaper -> Text -> Text
escapeWith escaper = T.concatMap render
where
render c = case escaper.escapeChar of
Just escape | c `elem` escaper.specialChars -> escape c
_ -> T.singleton c
-- | Generate a literal the syntax can represent, paired with its source form.
--
-- Which literals are representable depends on the escaper: one with an escape
-- mechanism can carry any special character, one without can only carry the
-- characters that aren't special in the first place.
escapedText :: Escaper -> Gen Char -> Int -> Int -> Gen EscapedText
escapedText escaper base lo hi = escaped escaper <$> textOf char lo hi
where
ordinary = base `suchThat` (`notElem` escaper.specialChars)
char = case escaper.escapeChar of
-- nothing needing an escape can be represented, so don't generate it
Nothing -> ordinary
Just _ -> frequency [(3, ordinary), (1, elements escaper.specialChars)]
escaped :: Escaper -> Text -> EscapedText
escaped escaper t = EscapedText {literal = t, rendered = escapeWith escaper t}
-- | Text destined for the Markdown parser's inline layer, in source form and in
-- the form it should come back out as. The two are equal for as long as
-- `markdownEscaper` is `unescapable`; pointing it at `backslashEscaper` once
-- Markdown.hs handles escapes turns every property using this into a test of
-- that handling, without the properties themselves changing.
newtype MarkdownText = MarkdownText EscapedText
deriving (Show)
instance Arbitrary MarkdownText where
arbitrary = MarkdownText <$> escapedText markdownEscaper alphaNumChar 1 20
shrink (MarkdownText t) = MarkdownText . escaped markdownEscaper <$> shrinkTextWith (not . T.null) t.literal