Files
roux/test/Roux/ParserSpec.hs
T

430 lines
20 KiB
Haskell

module Roux.ParserSpec (spec) where
import Data.List.NonEmpty (NonEmpty ((:|)))
import qualified Data.List.NonEmpty as NE
import Data.Ratio ((%))
import qualified Data.Text.IO as TIO
import Test.Hspec (Spec, describe, expectationFailure, it, shouldBe)
import Roux.Parser (parseCookFile)
import Roux.Types
spec :: Spec
spec = do
describe "parseCookFile" $ do
it "parses a single step of plain text" $ do
let input = "Hello, world."
expected =
Right
Recipe
{ recipeMetadata = emptyMetadata
, recipeSections =
Section
{ sectionName = Nothing
, sectionBody =
SecStep
(Step [StepText "Hello, world."])
:| []
}
:| []
}
parseCookFile input `shouldBe` expected
it "parses multiple steps separated by a blank line" $ do
let input = "Wash the potatoes.\n\nPeel and dice them."
expected =
Right
Recipe
{ recipeMetadata = emptyMetadata
, recipeSections =
Section
{ sectionName = Nothing
, sectionBody =
SecStep (Step [StepText "Wash the potatoes."])
:| [ SecStep
(Step [StepText "Peel and dice them."])
]
}
:| []
}
parseCookFile input `shouldBe` expected
it "handles empty input by returning an empty recipe" $ do
let expected =
Right
Recipe
{ recipeMetadata = emptyMetadata
, recipeSections =
Section
{ sectionName = Nothing
, sectionBody = SecStep (Step []) :| []
}
:| []
}
parseCookFile "" `shouldBe` expected
it "trims leading and trailing whitespace from each block" $ do
let input = "\n\n Mix well. \n\n"
expected =
Right
Recipe
{ recipeMetadata = emptyMetadata
, recipeSections =
Section
{ sectionName = Nothing
, sectionBody =
SecStep (Step [StepText "Mix well."]) :| []
}
:| []
}
parseCookFile input `shouldBe` expected
it "parses a full-line comment" $ do
let input = "-- TODO add source"
expected =
Right
Recipe
{ recipeMetadata = emptyMetadata
, recipeSections =
Section
{ sectionName = Nothing
, sectionBody = SecComment "TODO add source" :| []
}
:| []
}
parseCookFile input `shouldBe` expected
it "parses a note block" $ do
let input = "> Don't burn the roux!"
expected =
Right
Recipe
{ recipeMetadata = emptyMetadata
, recipeSections =
Section
{ sectionName = Nothing
, sectionBody = SecNote "Don't burn the roux!" :| []
}
:| []
}
parseCookFile input `shouldBe` expected
it "parses a section header" $ do
let input = "= Dough"
expected =
Right
Recipe
{ recipeMetadata = emptyMetadata
, recipeSections =
Section
{ sectionName = Just "Dough"
, sectionBody = SecStep (Step []) :| []
}
:| []
}
parseCookFile input `shouldBe` expected
it "groups items before a section header into an unnamed section" $ do
let input = "Mix flour and water.\n\n= Filling\n\nCombine cheese and spinach."
expected =
Right
Recipe
{ recipeMetadata = emptyMetadata
, recipeSections =
Section
{ sectionName = Nothing
, sectionBody =
SecStep
(Step [StepText "Mix flour and water."])
:| []
}
:| [ Section
{ sectionName = Just "Filling"
, sectionBody =
SecStep
(Step [StepText "Combine cheese and spinach."])
:| []
}
]
}
parseCookFile input `shouldBe` expected
describe "Ingredients" $ do
it "parses a simple ingredient with quantity" $ do
let input = "@eggs{3}"
expected =
Right
Recipe
{ recipeMetadata = emptyMetadata
, recipeSections =
Section
{ sectionName = Nothing
, sectionBody =
SecStep
( Step
[ StepIngredient
(Ingredient "eggs" (Just (Quantity 3 Nothing False)) Nothing)
]
)
:| []
}
:| []
}
parseCookFile input `shouldBe` expected
it "parses an ingredient with quantity and unit" $ do
let input = "@flour{125%g}"
expected =
Right
Recipe
{ recipeMetadata = emptyMetadata
, recipeSections =
Section
{ sectionName = Nothing
, sectionBody =
SecStep
( Step
[ StepIngredient
(Ingredient "flour" (Just (Quantity 125 (Just "g") False)) Nothing)
]
)
:| []
}
:| []
}
parseCookFile input `shouldBe` expected
it "parses an ingredient without quantity (single word)" $ do
let input = "drizzle of @oil"
expected =
Right
Recipe
{ recipeMetadata = emptyMetadata
, recipeSections =
Section
{ sectionName = Nothing
, sectionBody =
SecStep
( Step
[ StepText "drizzle of "
, StepIngredient
(Ingredient "oil" Nothing Nothing)
]
)
:| []
}
:| []
}
parseCookFile input `shouldBe` expected
it "parses a multi-word ingredient with empty braces" $ do
let input = "@sea salt{}"
expected =
Right
Recipe
{ recipeMetadata = emptyMetadata
, recipeSections =
Section
{ sectionName = Nothing
, sectionBody =
SecStep
( Step
[ StepIngredient
(Ingredient "sea salt" Nothing Nothing)
]
)
:| []
}
:| []
}
parseCookFile input `shouldBe` expected
it "parses a multi-word ingredient with quantity and unit" $ do
let input = "@sea salt{1%pinch}"
expected =
Right
Recipe
{ recipeMetadata = emptyMetadata
, recipeSections =
Section
{ sectionName = Nothing
, sectionBody =
SecStep
( Step
[ StepIngredient
(Ingredient "sea salt" (Just (Quantity 1 (Just "pinch") False)) Nothing)
]
)
:| []
}
:| []
}
parseCookFile input `shouldBe` expected
describe "Comments" $ do
it "parses an end-of-line comment within a step" $ do
let input = "Mash until smooth -- alternatively, boil 'em first"
case parseCookFile input of
Left err -> expectationFailure ("parse failed: " <> err)
Right recipe -> do
let body = NE.toList (sectionBody (NE.head (recipeSections recipe)))
case body of
[SecStep (Step items)] -> do
let expected =
[ StepText "Mash until smooth "
, StepEndComment "alternatively, boil 'em first"
]
items `shouldBe` expected
other -> expectationFailure ("expected single SecStep, got " <> show other)
it "parses an inline block comment" $ do
let input = "Slowly add @milk{4%cup} [- TODO change units to litres -], keep mixing"
case parseCookFile input of
Left err -> expectationFailure ("parse failed: " <> err)
Right recipe -> do
let body = NE.toList (sectionBody (NE.head (recipeSections recipe)))
case body of
[SecStep (Step items)] -> do
let expected =
[ StepText "Slowly add "
, StepIngredient (Ingredient "milk" (Just (Quantity 4 (Just "cup") False)) Nothing)
, StepText " "
, StepComment " TODO change units to litres "
, StepText ", keep mixing"
]
items `shouldBe` expected
other -> expectationFailure ("expected single SecStep, got " <> show other)
describe "EasyPancakes example" $ do
let pancakeInput =
"-- TODO add source\n"
<> "\n"
<> "Crack the @eggs{3} into a blender, then add the @flour{125%g}, @milk{250%ml} and @sea salt{1%pinch}, and blitz until smooth.\n"
<> "\n"
<> "Pour into a #bowl and leave to stand for ~{15%minutes}.\n"
<> "\n"
<> "Melt the butter (or a drizzle of @oil if you want to be a bit healthier) in a #large non-stick frying pan{} on a medium heat, then tilt the pan so the butter coats the surface.\n"
<> "\n"
<> "Pour in 1 ladle of batter and tilt again, so that the batter spreads all over the base, then cook for 1 to 2 minutes, or until it starts to come away from the sides.\n"
<> "\n"
<> "Once golden underneath, flip the pancake over and cook for 1 further minute, or until cooked through.\n"
<> "\n"
<> "Serve straightaway with your favourite topping.\n"
it "parses into 1 section with 7 body items" $ do
case parseCookFile pancakeInput of
Left err -> expectationFailure ("parse failed: " <> err)
Right recipe -> do
let section = NE.head (recipeSections recipe)
body = NE.toList (sectionBody section)
length body `shouldBe` 7
it "first body item is the full-line comment" $ do
case parseCookFile pancakeInput of
Left err -> expectationFailure ("parse failed: " <> err)
Right recipe -> do
let body = NE.toList (sectionBody (NE.head (recipeSections recipe)))
case body of
(SecComment t : _) -> t `shouldBe` "TODO add source"
other -> expectationFailure ("expected SecComment first, got " <> show other)
it "first step parses ingredients correctly" $ do
case parseCookFile pancakeInput of
Left err -> expectationFailure ("parse failed: " <> err)
Right recipe -> do
let body = NE.toList (sectionBody (NE.head (recipeSections recipe)))
case body of
(_ : SecStep (Step items) : _) -> do
let expected =
[ StepText "Crack the "
, StepIngredient (Ingredient "eggs" (Just (Quantity 3 Nothing False)) Nothing)
, StepText " into a blender, then add the "
, StepIngredient (Ingredient "flour" (Just (Quantity 125 (Just "g") False)) Nothing)
, StepText ", "
, StepIngredient (Ingredient "milk" (Just (Quantity 250 (Just "ml") False)) Nothing)
, StepText " and "
, StepIngredient (Ingredient "sea salt" (Just (Quantity 1 (Just "pinch") False)) Nothing)
, StepText ", and blitz until smooth."
]
items `shouldBe` expected
_ -> expectationFailure ("expected SecStep as second body item, got body=" <> show body)
it "second step parses cookware and timer" $ do
case parseCookFile pancakeInput of
Left err -> expectationFailure ("parse failed: " <> err)
Right recipe -> do
let body = NE.toList (sectionBody (NE.head (recipeSections recipe)))
case body of
(_ : _ : SecStep (Step items) : _) -> do
let expected =
[ StepText "Pour into a "
, StepCookware (Cookware "bowl")
, StepText " and leave to stand for "
, StepTimer (Timer Nothing (Duration 15 (Just "minutes")))
, StepText "."
]
items `shouldBe` expected
_ -> expectationFailure ("expected SecStep as third body item, got body=" <> show body)
describe "Quantities" $ do
it "parses compound quantity (1,1/2) with unit attached" $ do
let input = "@water{1,1/2cups}"
case parseCookFile input of
Left err -> expectationFailure ("parse failed: " <> err)
Right recipe -> do
let items = stepItems recipe
items
`shouldBe` [ StepIngredient (Ingredient "water" (Just (Quantity (3 % 2) (Just "cups") False)) Nothing)
]
it "parses quantity with trailing unit (no %)" $ do
let input = "@gelatine{3tsp}"
case parseCookFile input of
Left err -> expectationFailure ("parse failed: " <> err)
Right recipe -> do
let items = stepItems recipe
items
`shouldBe` [ StepIngredient (Ingredient "gelatine" (Just (Quantity 3 (Just "tsp") False)) Nothing)
]
describe "Recipe references" $ do
it "parses a recipe reference" $ do
let input = "Pour over with @./sauces/Hollandaise{150%g}."
case parseCookFile input of
Left err -> expectationFailure ("parse failed: " <> err)
Right recipe -> do
let items = stepItems recipe
let expected =
[ StepText "Pour over with "
, StepRecipeRef (RecipeRef "./sauces/Hollandaise" (Just (Quantity 150 (Just "g") False)))
, StepText "."
]
items `shouldBe` expected
describe "Full recipe files" $ do
let readExample name = do
content <- TIO.readFile ("cooklang-examples/" <> name <> ".cook")
case parseCookFile content of
Left err -> expectationFailure (name <> ": " <> err)
Right _ -> pure ()
it "EasyPancakes parses without error" $ do
readExample "EasyPancakes"
it "FriedRice parses without error" $ do
readExample "FriedRice"
it "CoffeeSouffle parses without error" $ do
readExample "CoffeeSouffle"
it "OlivierSalad parses without error" $ do
readExample "OlivierSalad"
-- | Extract step items from the first step of an unnamed section.
stepItems :: Recipe -> [StepItem]
stepItems r =
let s = NE.head (recipeSections r)
in case NE.head (sectionBody s) of
SecStep step -> unStep step
_ -> []