packages feed

svfactor-0.1: test/Data/Svfactor/ParseTest.hs

{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE OverloadedStrings #-}

module Data.Svfactor.ParseTest (test_Parse) where

import Control.Lens ((&), (.~))
import Data.ByteString (ByteString)
import qualified Data.ByteString.UTF8 as UTF8
import Data.List.NonEmpty (NonEmpty ((:|)), nonEmpty)
import Data.Either (isLeft)
import Data.Foldable (fold)
import Data.Semigroup (Semigroup ((<>)))
import Data.Text (Text, pack)
import Hedgehog
import Test.Tasty (TestName, TestTree, testGroup)
import Test.Tasty.Hedgehog (testProperty)
import Test.Tasty.HUnit (Assertion, assertBool, testCase, (@?=))
import Text.Parser.Char (CharParsing)
import Text.Trifecta (Result (Success, Failure), parseByteString, _errDoc)

import Data.Svfactor.Generators (genCsvString)
import Data.Svfactor.Parse (ParseOptions, defaultParseOptions, headedness, separator, encodeString)
import Data.Svfactor.Parse.Internal (doubleQuotedField, record, separatedValues, singleQuotedField, spaced, spacedField)
import Data.Svfactor.Syntax.Sv (Sv, mkSv, comma, pipe, tab, Headedness (Unheaded), Separator)
import Data.Svfactor.Syntax.Field (Field (Quoted, Unquoted), SpacedField)
import Data.Svfactor.Syntax.Record (Record (Record), recordNel, mkRecords, Records (EmptyRecords))
import Data.Svfactor.Text.Escape (Unescaped (Unescaped))
import Data.Svfactor.Text.Newline (Newline (CR, LF, CRLF), newlineToString)
import Data.Svfactor.Text.Space (Spaced (Spaced), manySpaces, unspaced)
import Data.Svfactor.Text.Quote (Quote (SingleQuote, DoubleQuote), quoteToString)

test_Parse :: TestTree
test_Parse =
  testGroup "Parse" [
    singleQuotedFieldTest
  , doubleQuotedFieldTest
  , fieldTest
  , recordTest
  , csvTest
  , psvTest
  , tsvTest
  , nsvTest
  , crsvTest
  , bssvTest
  , randomCsvTest
  ]

r2e :: Result a -> Either String a
r2e r = case r of
  Success a -> Right a
  Failure e -> Left (show (_errDoc e))

(@?=/) ::
  (Show a, Show b, Eq a, Eq b)
  => Either a b
  -> b
  -> Assertion
(@?=/) l r = l @?= pure r

qd, qs :: a -> Field a
qd = Quoted DoubleQuote . Unescaped
qs = Quoted SingleQuote . Unescaped
qsr :: s -> Record s
qsr = Record . pure . nospc . qs
uq :: s -> SpacedField s
uq = unspaced . Unquoted
uqa :: NonEmpty s -> Record s
uqa = recordNel . fmap uq
uqaa :: [NonEmpty s] -> [Record s]
uqaa = fmap uqa
nospc :: Field s -> SpacedField s
nospc = unspaced

quotedFieldTest :: (forall m . CharParsing m => m (SpacedField Text)) -> TestName -> Quote -> TestTree
quotedFieldTest parser name quote =
  let p :: [ByteString] -> Either String (SpacedField Text)
      p = r2e . parseByteString parser mempty . mconcat
      q = quoteToString quote
      qq = Quoted quote . Unescaped
  in testGroup name [
    testCase "empty" $
      p [q,q] @?=/ nospc (qq "")
  , testCase "text" $
      p [q,"hello text",q]
        @?=/ nospc (qq "hello text")
  , testCase "capture space" $
      p ["   ", q, " spaced text  ", q, "     "]
        @?=/ Spaced (manySpaces 3) (manySpaces 5) (qq " spaced text  ")
  , testCase "no closing quote" $
      assertBool "wasn't left" (isLeft (p [q, "no closing quote"   ]))
  , testCase "no opening quote" $
      assertBool "wasn't left" (isLeft (p [   "no opening quote", q]))
  , testCase "no quotes" $
      assertBool "wasn't left" (isLeft (p [   "no quotes"          ]))
  , testCase "quoted field can handle escaped quotes" $
     p [q,"yes", q, q, "no", q] @?=/ nospc (Quoted quote (Unescaped ("yes" <> quoteToString quote <> "no")))
  ]

singleQuotedFieldTest, doubleQuotedFieldTest :: TestTree
singleQuotedFieldTest = quotedFieldTest (spaced comma (singleQuotedField pack)) "singleQuotedField" SingleQuote
doubleQuotedFieldTest = quotedFieldTest (spaced comma (doubleQuotedField pack)) "doubleQuotedField" DoubleQuote

fieldTest :: TestTree
fieldTest =
  let p :: ByteString -> Either String (SpacedField Text)
      p = r2e . parseByteString (spacedField comma pack) mempty
  in  testGroup "field" [
    testCase "doublequoted" $
      p "\"hello\"" @?=/ nospc (qd "hello")
  , testCase "singlequoted" $
      p "'goodbye'" @?=/ nospc (qs "goodbye")
  , testCase "unquoted" $
      p "yes" @?=/ uq "yes"
  , testCase "spaced doublequoted" $
      p "       \" spaces  \"    " @?=/ Spaced (manySpaces 7) (manySpaces 4) (qd " spaces  ")
  , testCase "spaced singlequoted" $
      p "        ' more spaces ' " @?=/ Spaced (manySpaces 8) (manySpaces 1) (qs " more spaces ")
  , testCase "spaced unquoted" $
      p "  some text   " @?=/ Spaced (manySpaces 2) (manySpaces 3) (Unquoted "some text")
  , testCase "fields can include the separator in single quotes" $
      p "'hello,there,'" @?=/ nospc (qs "hello,there,")
  , testCase "fields can include the separator in double quotes" $
      p "\"court,of,the,,,,crimson,king\"" @?=/ nospc (qd "court,of,the,,,,crimson,king")
  , testCase "unquoted fields stop at the separator (1)" $
      p "close,to,the,edge" @?=/ uq "close"
  , testCase "unquoted fields stop at the separator (2)" $
      p ",close,to,the,edge" @?=/ uq ""
  ]

recordTest :: TestTree
recordTest =
  let opts :: ParseOptions Text
      opts = defaultParseOptions & encodeString .~ pack
      p :: ByteString -> Either String (Record Text)
      p = r2e . parseByteString (record opts) mempty
  in  testGroup "record" [
    testCase "single field" $
      p "Yes" @?=/ uqa (pure "Yes")
  , testCase "fields" $
      p "Anderson,Squire,Wakeman,Howe,Bruford" @?=/ uqa ("Anderson":|["Squire", "Wakeman", "Howe", "Bruford"])
  , testCase "commas" $
      p ",,," @?=/ uqa ("":|["","",""])
  , testCase "record ends at newline" $
      p "a,b,c\nd,e,f" @?=/ uqa ("a":|["b","c"])
  , testCase "record ends at carriage return" $
      p "g,h,i\rj,k,l" @?=/ uqa ("g":|["h","i"])
  , testCase "record ends at carriage return followed by newline" $
      p "m,n,o\r\np,q,r" @?=/ uqa ("m":|["n","o"])
  ]

separatedValuesTest :: Separator -> Newline -> Int -> TestTree
separatedValuesTest sep nl newlines =
  let opts = defaultParseOptions & separator .~ sep & headedness .~ Unheaded & encodeString .~ pack
      p :: ByteString -> Either String (Sv Text)
      p = r2e . parseByteString (separatedValues opts) mempty
      ps = p . fold
      mkSv' :: [Record s] -> [Newline] -> Sv s
      mkSv' rs e = mkSv sep Nothing e $ maybe EmptyRecords (mkRecords nl) $ nonEmpty rs
      s = UTF8.fromString [sep]
      nls = newlineToString nl
      terminator = replicate newlines nl
      termStr = foldMap newlineToString terminator
  in  testGroup "separatedValues" [
    testCase "empty" $
      ps ["", termStr] @?=/ mkSv' [] terminator
  , testCase "single empty quotes field" $ 
      ps ["''", termStr] @?=/ mkSv' [qsr ""] terminator
  , testCase "single field, single record" $
      ps ["one", termStr] @?=/ mkSv' [uqa (pure "one")] terminator
  , testCase "single field, multiple records" $
      ps ["one",nls,"un",termStr] @?=/ mkSv' [uqa (pure "one"), uqa (pure "un")] terminator
  , testCase "multiple fields, single record" $
      ps ["one", s, "two",termStr] @?=/ mkSv' (uqaa (pure ("one":|["two"]))) terminator
  , testCase "multiple fields, multiple records" $
      ps ["one", s, "two", s, "three", nls, "un", s, "deux", s, "trois",termStr]
        @?=/ mkSv' (uqaa ["one":|["two", "three"] , "un":|["deux", "trois"]]) terminator
  ]

svTest :: String -> Separator -> TestTree
svTest name sep =
  testGroup name $ separatedValuesTest sep <$> [CR, LF, CRLF] <*> [0,1,2]

csvTest :: TestTree
csvTest = svTest "csv" comma

psvTest :: TestTree
psvTest = svTest "psv" pipe

tsvTest :: TestTree
tsvTest = svTest "tsv" tab

nsvTest :: TestTree
nsvTest = svTest "NULL separated values" '\0'

crsvTest :: TestTree
crsvTest =
  testGroup "carriage return separated values" $
    separatedValuesTest '\r' <$> [LF] <*> [0,1,2]

bssvTest :: TestTree
bssvTest = svTest "backspace separated values" '\BS'

prop_randomCsvTest :: Property
prop_randomCsvTest = property $ do
  str <- forAll genCsvString
  let opts = separatedValues (defaultParseOptions & headedness .~ Unheaded & encodeString .~ id)
      x :: Either String (Sv String)
      x = r2e (parseByteString opts mempty str)
  case x of
    Left _ -> failure
    Right _ -> success

randomCsvTest :: TestTree
randomCsvTest =
  testProperty "parse random CSV" prop_randomCsvTest