430 lines
20 KiB
Haskell
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 Data.CookLang
|
|
import Roux.Parser (parseCookFile)
|
|
|
|
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
|
|
_ -> []
|