Phase 1: Model update + file-level parser (text-only steps)
- Update Roux.Types: add SectionBodyItem (SecStep/SecComment/SecNote), StepEndComment, remove StepNote (notes are now section-level) - Rewrite Roux.Parser using Parsec for inline elements, text-splitting for file-level structure - Handle full-line -- comments, > notes, = Name = section headers - Handle = Name (w/ and w/o closing =), == Name == variations - Handle end-of-line backslash line breaks within steps - Update tests to match new model + 3 EasyPancakes integration tests - Add expectTextStep helper at module level
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
-- make caramel
|
||||
|
||||
Crack the @egg yoke{1} into a bowl, then add the @condenced milk{125%g} and @instant coffee{3tsp}, and mix until a nice caramel is formed. Do not allow to bubble.
|
||||
|
||||
Pour into a #bowl and leave to cool down for ~{15%minutes}.
|
||||
|
||||
heat @water{1,1/2cups} and @gelatine{3tsp} then stir till gelatine is dissolved completely.Once gelatine is colled down mix it with the caramel.
|
||||
|
||||
Beat @eggwhite{1} until formy. Then mix the whites bit by bit to the the gelatinecaramel mixture.
|
||||
|
||||
|
||||
Pour into a #puddingbowl and leave in fridge for 2 hours minimum. Add some @cashew{60%g} and enjoy!
|
||||
@@ -0,0 +1,13 @@
|
||||
-- TODO add source
|
||||
|
||||
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.
|
||||
|
||||
Pour into a #bowl and leave to stand for ~{15%minutes}.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Once golden underneath, flip the pancake over and cook for 1 further minute, or until cooked through.
|
||||
|
||||
Serve straightaway with your favourite topping.
|
||||
@@ -0,0 +1,19 @@
|
||||
-- TODO add source
|
||||
|
||||
Mix together @oyster sauce{1%tbsp}, @soy sauce{5%tbsp} and @sesame oil{5%tsp}, set aside.
|
||||
|
||||
Heat @peanut oil{1%tbsp} in a wok or heavy based skillet/fry pan over medium heat. Add @eggs{2%items} and cook until scrambled, then remove onto plate.
|
||||
|
||||
Increase heat to high and add chopped @bacon{75%cup}, excess fat trimmed. Cook until golden, then remove onto plate with egg - about 2 minutes.
|
||||
|
||||
Drain off excess bacon fat.
|
||||
|
||||
Add remaining @peanut oil{2%tbsp}, minced @garlic{2%gloves} and @ginger{3%tsp}. Return wok to stove on high heat. Stir fry as it comes up to heat, don’t let it burn. When garlic starts to sizzle, add finally chopped @onion{2%medium} and stir fry for 2 minutes until golden.
|
||||
|
||||
Add @Chinese cooking wine{1%tbsp} and @sugar{2%tsp} and let it simmer rapidly, stirring, for 20 seconds, until mostly evaporated.
|
||||
|
||||
Add @prawns{150%g} and stir fry for 1 minute to heat through if pre-cooked, cook longer to cook if raw.
|
||||
|
||||
Add refrigerated overnight @cooked rice{3%cups}, Sauce, eggs, bacon and thinly sliced @scallions{5%items}. Stir fry for 2 minutes until rice is hot - around 2 minutes.
|
||||
|
||||
Transfer to serving plate, sprinkle with thinly sliced @scallions{2%items} and serve.
|
||||
@@ -0,0 +1,27 @@
|
||||
-- TODO add source
|
||||
|
||||
Zero step is cook @corn beef{1%kg}. Put into a large pan and simmer for ~{2%hours}.
|
||||
|
||||
The first step is to cook your @potatoes{3%medium} and @carrots{3%medium}. I used a steamer, but you can always go the traditional route and boil them. In either case, peel the carrots but not the potatoes.
|
||||
|
||||
Steam the potatoes for ~{30%minutes} to start with, and then add the peeled carrots. Continue steaming for 10-15 more minutes, or until the potatoes and carrots are firm but tender when poked.
|
||||
|
||||
Meanwhile, cook your @frozen peas{1%cup} according to package directions. I use the kind that can be steamed in the package in the microwave. When they are done, set them aside to cool.
|
||||
|
||||
When the potatoes and carrots are done, allow them to cool to the point that you can handle them easily.
|
||||
|
||||
Peel the potatoes. Using your fingers or the back of a knife, gently scrape the thin layer of skin off of the potatoes. Dice them into 1cm cube-ish shapes and put them into a medium serving bowl.
|
||||
|
||||
Next, dice your carrots. I've heard it said that a Soviet housewife could be judged on her housekeeping skills by how finely she could dice vegetables for her soups and salads. I, however, won't judge you. In fact, if you chop your potatoes and carrots a little larger, I would probably even thank you. I happen to like chunky salads.
|
||||
|
||||
Toss the carrots and a cup of steamed peas into the bowl with the potatoes.
|
||||
|
||||
Peel and dice your hardboiled @eggs{4}. Again, I know some like to have their salads with finely diced ingredients, but I don't. So dice them however you like.
|
||||
|
||||
Chop @pickles{6} finely. I used small snacking dill pickles, so I needed to use six of them. If you have larger pickles, try using three and see if that is enough for you.
|
||||
|
||||
Add the meat if using and mix everything together gently before you add the @mayonnaise{1%cup}.
|
||||
|
||||
Stir in one cup of mayo to start with, and add more if you think that the salad needs more binding together.
|
||||
|
||||
Cover the salad and chill for at least one hour or overnight to allow the flavors to come together. And of course, garnish with finelly chopped @dill{1%tbsp}. This is a Russian salad, after all.
|
||||
+184
-23
@@ -1,8 +1,8 @@
|
||||
{- | Parser for Cooklang (.cook) recipe files.
|
||||
|
||||
Currently a minimal placeholder that splits input into paragraphs and
|
||||
treats them as plain-text steps. Proper inline parsing (ingredients,
|
||||
cookware, timers, …) and metadata support will be added incrementally.
|
||||
Parses the full file structure: full-line comments, notes, section headers,
|
||||
and steps. Inline element parsing (ingredients, cookware, timers, …) is
|
||||
added incrementally in follow-up commits.
|
||||
-}
|
||||
module Roux.Parser (
|
||||
parseCookFile,
|
||||
@@ -11,33 +11,194 @@ module Roux.Parser (
|
||||
import Data.List.NonEmpty (NonEmpty ((:|)))
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import qualified Text.Parsec as P
|
||||
import Text.Parsec.Text (Parser)
|
||||
|
||||
import Roux.Types
|
||||
|
||||
{- | Parse a complete @.cook@ file into a 'Recipe'.
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Internal raw-block types (parser only)
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
Returns 'Left' with an error message on invalid input (currently never
|
||||
— the placeholder always succeeds).
|
||||
{- | The result of classifying a top-level block before building the final
|
||||
'SectionBodyItem' tree.
|
||||
-}
|
||||
data RawBlock
|
||||
= RawStep !Step
|
||||
| RawComment !Text
|
||||
| RawNote !Text
|
||||
| RawSectionHeader !Text
|
||||
deriving stock (Eq, Show)
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Entry point
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- | Parse a complete @.cook@ file into a 'Recipe'.
|
||||
parseCookFile :: Text -> Either String Recipe
|
||||
parseCookFile input =
|
||||
let paragraphs = filter (not . T.null) (map T.strip (T.splitOn "\n\n" (T.strip input)))
|
||||
in case paragraphs of
|
||||
let trimmed = T.strip input
|
||||
rawBlocks = splitByBlankLines trimmed
|
||||
nonEmpty = filter (not . T.null) rawBlocks
|
||||
in case nonEmpty of
|
||||
[] ->
|
||||
Right
|
||||
Recipe
|
||||
{ recipeMetadata = emptyMetadata
|
||||
, recipeSections = Section Nothing (Step [] :| []) :| []
|
||||
}
|
||||
(p : ps) ->
|
||||
Right
|
||||
Recipe
|
||||
{ recipeMetadata = emptyMetadata
|
||||
, recipeSections = Section Nothing (stepFromText p :| fmap stepFromText ps) :| []
|
||||
}
|
||||
Right emptyRecipe
|
||||
blocks ->
|
||||
case traverse classifyBlock blocks of
|
||||
Left err -> Left err
|
||||
Right items -> Right (buildRecipe items)
|
||||
|
||||
{- | Turn a paragraph of text into a single 'Step' containing nothing but
|
||||
'StepText'. This is a placeholder until inline parsing is implemented.
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- File-level structure
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
{- | Split text into blocks separated by one or more blank lines,
|
||||
preserving each block's internal newlines.
|
||||
-}
|
||||
stepFromText :: Text -> Step
|
||||
stepFromText t = Step [StepText t]
|
||||
splitByBlankLines :: Text -> [Text]
|
||||
splitByBlankLines = map T.strip . T.splitOn "\n\n"
|
||||
|
||||
-- | Classify a single top-level block.
|
||||
classifyBlock :: Text -> Either String RawBlock
|
||||
classifyBlock block
|
||||
| "--" `T.isPrefixOf` trimmed =
|
||||
Right (RawComment (T.strip (T.drop 2 trimmed)))
|
||||
| ">" `T.isPrefixOf` trimmed =
|
||||
Right (RawNote (T.strip (T.drop 1 trimmed)))
|
||||
| Just name <- stripSectionHeader trimmed =
|
||||
Right (RawSectionHeader name)
|
||||
| otherwise =
|
||||
case P.parse (pStepBlock <* P.eof) "" trimmed of
|
||||
Left err -> Left (show err)
|
||||
Right step -> Right (RawStep step)
|
||||
where
|
||||
trimmed = T.strip block
|
||||
|
||||
{- | Try to extract a section name from header markup.
|
||||
|
||||
Recognises @= Name =@, @= Name@, @== Name ==@, etc. One or more @=@
|
||||
signs on each side, with optional whitespace inside.
|
||||
-}
|
||||
stripSectionHeader :: Text -> Maybe Text
|
||||
stripSectionHeader t =
|
||||
let stripped = T.strip t
|
||||
in case T.break (/= '=') stripped of
|
||||
-- At least one leading =
|
||||
(leadingEq, afterEq)
|
||||
| not (T.null leadingEq) ->
|
||||
let afterSpace = T.strip afterEq
|
||||
in case T.break (== '=') afterSpace of
|
||||
-- No trailing = signs -> the whole thing is the name
|
||||
(name, trailingEqs)
|
||||
| T.all (== '=') (T.strip trailingEqs)
|
||||
, not (T.null (T.strip name)) ->
|
||||
Just (T.strip name)
|
||||
_ -> Nothing
|
||||
_ -> Nothing
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Step-level parser (Parsec)
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
-- | Parse a single step block (plain text only for now).
|
||||
pStepBlock :: Parser Step
|
||||
pStepBlock = do
|
||||
items <- P.many pStepItem
|
||||
return (Step items)
|
||||
|
||||
-- | Parse one inline item within a step.
|
||||
pStepItem :: Parser StepItem
|
||||
pStepItem =
|
||||
pLineBreak
|
||||
P.<|> pText
|
||||
|
||||
-- | End-of-line backslash line break.
|
||||
pLineBreak :: Parser StepItem
|
||||
pLineBreak = do
|
||||
_ <- P.char '\\'
|
||||
P.optional (P.char '\r')
|
||||
_ <- P.newline
|
||||
return StepBreak
|
||||
|
||||
-- | Plain text (everything up to a special character or newline).
|
||||
pText :: Parser StepItem
|
||||
pText = do
|
||||
chars <- P.many1 (P.noneOf ['\\', '\n'])
|
||||
return (StepText (T.pack chars))
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Recipe construction
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
{- | Build a 'Recipe' from classified blocks, grouping into sections at
|
||||
'RawSectionHeader' boundaries.
|
||||
-}
|
||||
buildRecipe :: [RawBlock] -> Recipe
|
||||
buildRecipe items =
|
||||
case sections of
|
||||
[] -> emptyRecipe
|
||||
(s : ss) ->
|
||||
Recipe
|
||||
{ recipeMetadata = emptyMetadata
|
||||
, recipeSections = s :| ss
|
||||
}
|
||||
where
|
||||
sections = groupSections items
|
||||
|
||||
{- | Group a flat list of 'RawBlock' values into sections, splitting at
|
||||
'RawSectionHeader' boundaries.
|
||||
-}
|
||||
groupSections :: [RawBlock] -> [Section]
|
||||
groupSections [] = []
|
||||
groupSections items =
|
||||
case span (not . isSectionHeader) items of
|
||||
-- No section header: everything goes into an unnamed section
|
||||
(body, []) ->
|
||||
[Section Nothing (makeNonEmpty (map rawToBody body))]
|
||||
-- Items before the first header → unnamed section, then named
|
||||
(before, (RawSectionHeader name) : rest) ->
|
||||
let pre =
|
||||
if null before
|
||||
then []
|
||||
else [Section Nothing (makeNonEmpty (map rawToBody before))]
|
||||
named = collectNamedSection name rest
|
||||
in pre ++ named
|
||||
-- Unreachable: 'span' never returns a non-header as the first item
|
||||
-- of the second component, but GHC doesn't know that.
|
||||
_ -> error "groupSections: impossible"
|
||||
|
||||
-- | Is this block a section header?
|
||||
isSectionHeader :: RawBlock -> Bool
|
||||
isSectionHeader (RawSectionHeader _) = True
|
||||
isSectionHeader _ = False
|
||||
|
||||
-- | Collect a named section: from the header to the next header (or end).
|
||||
collectNamedSection :: Text -> [RawBlock] -> [Section]
|
||||
collectNamedSection name items =
|
||||
case span (not . isSectionHeader) items of
|
||||
(body, []) ->
|
||||
[Section (Just name) (makeNonEmpty (map rawToBody body))]
|
||||
(body, (RawSectionHeader nextName) : rest) ->
|
||||
Section (Just name) (makeNonEmpty (map rawToBody body))
|
||||
: collectNamedSection nextName rest
|
||||
_ -> error "collectNamedSection: impossible"
|
||||
|
||||
-- | Convert a 'RawBlock' to a 'SectionBodyItem'.
|
||||
rawToBody :: RawBlock -> SectionBodyItem
|
||||
rawToBody (RawStep s) = SecStep s
|
||||
rawToBody (RawComment t) = SecComment t
|
||||
rawToBody (RawNote t) = SecNote t
|
||||
rawToBody (RawSectionHeader _) = error "rawToBody: section header should not appear here"
|
||||
|
||||
-- | Convert a list to 'NonEmpty', using a single empty step as default.
|
||||
makeNonEmpty :: [SectionBodyItem] -> NonEmpty SectionBodyItem
|
||||
makeNonEmpty [] = SecStep (Step []) :| []
|
||||
makeNonEmpty (x : xs) = x :| xs
|
||||
|
||||
-- | An empty recipe (no metadata, one unnamed section with one empty step).
|
||||
emptyRecipe :: Recipe
|
||||
emptyRecipe =
|
||||
Recipe
|
||||
{ recipeMetadata = emptyMetadata
|
||||
, recipeSections = Section Nothing (SecStep (Step []) :| []) :| []
|
||||
}
|
||||
|
||||
+23
-10
@@ -10,10 +10,15 @@ Recipe
|
||||
|
||||
Section
|
||||
Maybe Text — optional section name ("Dough", "Filling")
|
||||
NonEmpty Step — at least one step per section
|
||||
NonEmpty SectionBodyItem — interleaved steps, comments, and notes
|
||||
|
||||
SectionBodyItem
|
||||
SecStep Step — a cooking step (paragraph)
|
||||
SecComment Text — full-line @--@ comment
|
||||
SecNote Text — @>@ note / commentary
|
||||
|
||||
Step
|
||||
[StepItem] — sequence of inline elements
|
||||
[StepItem]
|
||||
|
||||
StepItem
|
||||
StepText — plain text
|
||||
@@ -21,8 +26,8 @@ StepItem
|
||||
StepCookware — \#name{}
|
||||
StepTimer — \~name{duration%unit}
|
||||
StepRecipeRef — \@.\/path\/to\/recipe{amount}
|
||||
StepNote — \> note / commentary
|
||||
StepComment — [- comment -]
|
||||
StepEndComment — @-- end of line comment@
|
||||
StepComment — @[- block comment -]@
|
||||
StepBreak — \\ at end of line
|
||||
@
|
||||
-}
|
||||
@@ -30,8 +35,9 @@ module Roux.Types (
|
||||
-- * Recipe
|
||||
Recipe (..),
|
||||
|
||||
-- * Sections and steps
|
||||
-- * Sections and body items
|
||||
Section (..),
|
||||
SectionBodyItem (..),
|
||||
Step (..),
|
||||
|
||||
-- * Inline elements
|
||||
@@ -67,18 +73,25 @@ data Recipe = Recipe
|
||||
deriving stock (Eq, Show)
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Sections and steps
|
||||
-- Sections and body items
|
||||
-- ---------------------------------------------------------------------------
|
||||
|
||||
{- | A named or unnamed section within a recipe. Each section contains one
|
||||
or more cooking steps.
|
||||
{- | A named or unnamed section within a recipe. Each section contains
|
||||
interleaved steps, full-line comments, and notes.
|
||||
-}
|
||||
data Section = Section
|
||||
{ sectionName :: !(Maybe Text)
|
||||
, sectionSteps :: !(NonEmpty Step)
|
||||
, sectionBody :: !(NonEmpty SectionBodyItem)
|
||||
}
|
||||
deriving stock (Eq, Show)
|
||||
|
||||
-- | An item that appears at the top-level of a section (between blank lines).
|
||||
data SectionBodyItem
|
||||
= SecStep !Step
|
||||
| SecComment !Text
|
||||
| SecNote !Text
|
||||
deriving stock (Eq, Show)
|
||||
|
||||
{- | A single cooking step — one paragraph of text in the Cooklang file,
|
||||
separated from other steps by a blank line.
|
||||
-}
|
||||
@@ -98,7 +111,7 @@ data StepItem
|
||||
| StepCookware !Cookware
|
||||
| StepTimer !Timer
|
||||
| StepRecipeRef !RecipeRef
|
||||
| StepNote !Text
|
||||
| StepEndComment !Text
|
||||
| StepComment !Text
|
||||
| StepBreak
|
||||
deriving stock (Eq, Show)
|
||||
|
||||
+133
-13
@@ -1,13 +1,14 @@
|
||||
module Roux.ParserSpec (spec) where
|
||||
|
||||
import Data.List.NonEmpty (NonEmpty ((:|)))
|
||||
import Test.Hspec (Spec, describe, it, shouldBe)
|
||||
import qualified Data.List.NonEmpty as NE
|
||||
import Test.Hspec (Expectation, Spec, describe, expectationFailure, it, shouldBe)
|
||||
|
||||
import Roux.Parser (parseCookFile)
|
||||
import Roux.Types
|
||||
|
||||
spec :: Spec
|
||||
spec =
|
||||
spec = do
|
||||
describe "parseCookFile" $ do
|
||||
it "parses a single step of plain text" $ do
|
||||
let input = "Hello, world."
|
||||
@@ -18,10 +19,9 @@ spec =
|
||||
, recipeSections =
|
||||
Section
|
||||
{ sectionName = Nothing
|
||||
, sectionSteps =
|
||||
Step
|
||||
{ unStep = [StepText "Hello, world."]
|
||||
}
|
||||
, sectionBody =
|
||||
SecStep
|
||||
(Step [StepText "Hello, world."])
|
||||
:| []
|
||||
}
|
||||
:| []
|
||||
@@ -37,9 +37,10 @@ spec =
|
||||
, recipeSections =
|
||||
Section
|
||||
{ sectionName = Nothing
|
||||
, sectionSteps =
|
||||
Step{unStep = [StepText "Wash the potatoes."]}
|
||||
:| [ Step{unStep = [StepText "Peel and dice them."]}
|
||||
, sectionBody =
|
||||
SecStep (Step [StepText "Wash the potatoes."])
|
||||
:| [ SecStep
|
||||
(Step [StepText "Peel and dice them."])
|
||||
]
|
||||
}
|
||||
:| []
|
||||
@@ -54,13 +55,13 @@ spec =
|
||||
, recipeSections =
|
||||
Section
|
||||
{ sectionName = Nothing
|
||||
, sectionSteps = Step{unStep = []} :| []
|
||||
, sectionBody = SecStep (Step []) :| []
|
||||
}
|
||||
:| []
|
||||
}
|
||||
parseCookFile "" `shouldBe` expected
|
||||
|
||||
it "trims leading and trailing whitespace from each step" $ do
|
||||
it "trims leading and trailing whitespace from each block" $ do
|
||||
let input = "\n\n Mix well. \n\n"
|
||||
expected =
|
||||
Right
|
||||
@@ -69,9 +70,128 @@ spec =
|
||||
, recipeSections =
|
||||
Section
|
||||
{ sectionName = Nothing
|
||||
, sectionSteps =
|
||||
Step{unStep = [StepText "Mix well."]} :| []
|
||||
, 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 "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 "remaining 6 items are text-only steps" $ 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
|
||||
(_comment : steps) -> mapM_ expectTextStep steps
|
||||
[] -> expectationFailure "empty body"
|
||||
|
||||
-- | Check that a body item is a 'SecStep' containing a single 'StepText'.
|
||||
expectTextStep :: SectionBodyItem -> Expectation
|
||||
expectTextStep (SecStep (Step [StepText _])) = pure ()
|
||||
expectTextStep other =
|
||||
expectationFailure
|
||||
("expected SecStep with single StepText, got " <> show other)
|
||||
|
||||
Reference in New Issue
Block a user