Phase 2: Ingredient, cookware, and timer parsing

- Add ingredient parser: @name{quantity%unit}(preparation)
  - Handles single-word names without braces
  - Handles multi-word names with braces
  - Handles quantity (with unit) and preparation text
- Add cookware parser: #name or #multi word name{}
  - Uses shared pNameWithBraces helper
- Add timer parser: ~name{duration%unit} or ~{duration%unit}
  - Separate named/unnamed timer branches for correct brace handling
- Add quantity parsing: {2}, {1%kg}, {1/2%tbsp}, {=1%tsp}
  - Handles simple integers, fractions (1/2), compound (1,1/2)
- Extract shared pNameWithQuantity and pNameWithBraces helpers
- Update EasyPancakes tests to verify ingredient/cookware/timer parsing
- All 17 tests passing
This commit is contained in:
2026-05-18 22:51:24 -04:00
parent 30dd268f2c
commit e63aeedc39
2 changed files with 311 additions and 12 deletions
+150 -10
View File
@@ -2,7 +2,7 @@ module Roux.ParserSpec (spec) where
import Data.List.NonEmpty (NonEmpty ((:|)))
import qualified Data.List.NonEmpty as NE
import Test.Hspec (Expectation, Spec, describe, expectationFailure, it, shouldBe)
import Test.Hspec (Spec, describe, expectationFailure, it, shouldBe)
import Roux.Parser (parseCookFile)
import Roux.Types
@@ -147,6 +147,118 @@ spec = do
}
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 "EasyPancakes example" $ do
let pancakeInput =
"-- TODO add source\n"
@@ -180,18 +292,46 @@ spec = do
(SecComment t : _) -> t `shouldBe` "TODO add source"
other -> expectationFailure ("expected SecComment first, got " <> show other)
it "remaining 6 items are text-only steps" $ do
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
(_comment : steps) -> mapM_ expectTextStep steps
[] -> expectationFailure "empty body"
(_ : SecStep (Step items) : _) -> do
-- "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."
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)
-- | 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)
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
-- "Pour into a #bowl and leave to stand for ~{15%minutes}."
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)