c33a51d80b
Build and Deploy / build-and-deploy (push) Successful in 1m14s
Adds checkStepCount helper that verifies the number of instructions in the parsed schema.org recipe matches the number of SecStep items in the Method section of the resulting Cooklang Recipe. Tests both fried-rice (4 steps) and carrot-risotto (7 steps).
134 lines
6.8 KiB
Haskell
134 lines
6.8 KiB
Haskell
module Roux.SchemaOrgSpec (spec) where
|
|
|
|
import Data.Aeson (Value, decodeStrict)
|
|
import Data.CookLang (Duration (..), Metadata (..), Recipe (..), Section (..), SectionBodyItem (..))
|
|
import Data.List.NonEmpty (toList)
|
|
import Data.Maybe (fromJust, isJust)
|
|
import Data.Text (Text)
|
|
import qualified Data.Text as T
|
|
import qualified Data.Text.Encoding as TE
|
|
import Test.Hspec (Spec, describe, expectationFailure, it, shouldBe, shouldSatisfy)
|
|
|
|
import Roux.SchemaOrg
|
|
|
|
-- | Extract the first schema.org Recipe JSON-LD from HTML.
|
|
tryDecode :: Text -> Maybe Value
|
|
tryDecode html =
|
|
case T.splitOn "<script type=\"application/ld+json" html of
|
|
(_before : rest : _) ->
|
|
-- Skip past the closing > of the opening script tag, which may have extra attributes
|
|
let afterOpenTag = T.drop 1 (T.dropWhile (/= '>') rest)
|
|
in case T.splitOn "</script>" afterOpenTag of
|
|
(jsonContent : _) -> decodeStrict (TE.encodeUtf8 (T.strip jsonContent))
|
|
_ -> Nothing
|
|
_ -> Nothing
|
|
|
|
spec :: Spec
|
|
spec = describe "SchemaOrg" $ do
|
|
describe "parseSchemaOrgRecipe" $ do
|
|
it "parses fried-rice.html embedded JSON-LD" $ do
|
|
html <- readFile "test-data/fried-rice.html"
|
|
let decoded = tryDecode (T.pack html)
|
|
decoded `shouldSatisfy` isJust
|
|
let recipe = parseSchemaOrgRecipe (fromJust decoded)
|
|
recipe `shouldSatisfy` isJust
|
|
case recipe of
|
|
Nothing -> expectationFailure "expected Just SchemaOrgRecipe, got Nothing"
|
|
Just r -> do
|
|
soName r `shouldBe` "Fried Rice"
|
|
soRecipeYield r `shouldBe` Just "4 to 6 servings"
|
|
soTotalTime r `shouldBe` Just "PT20M"
|
|
soRecipeCuisine r `shouldBe` Just "asian"
|
|
length (soRecipeIngredient r) `shouldBe` 14
|
|
length (soRecipeInstructions r) `shouldBe` 4
|
|
|
|
it "parses carrot-risotto.html embedded JSON-LD" $ do
|
|
html <- readFile "test-data/carrot-risotto.html"
|
|
let decoded = tryDecode (T.pack html)
|
|
decoded `shouldSatisfy` isJust
|
|
let recipe = parseSchemaOrgRecipe (fromJust decoded)
|
|
recipe `shouldSatisfy` isJust
|
|
case recipe of
|
|
Nothing -> expectationFailure "expected Just SchemaOrgRecipe, got Nothing"
|
|
Just r -> soName r `shouldBe` "Carrot Risotto With Chile Crisp"
|
|
|
|
describe "parseISODuration" $ do
|
|
let chk :: Text -> Maybe Duration -> IO ()
|
|
chk input expected = parseISODuration input `shouldBe` expected
|
|
|
|
it "PT20M -> 20 minutes" $ chk "PT20M" (Just (Duration 20 (Just "minutes")))
|
|
it "PT1H -> 60 minutes" $ chk "PT1H" (Just (Duration 60 (Just "minutes")))
|
|
it "PT1H30M -> 90 minutes" $ chk "PT1H30M" (Just (Duration 90 (Just "minutes")))
|
|
it "P1D -> 1440 minutes" $ chk "P1D" (Just (Duration 1440 (Just "minutes")))
|
|
it "P1DT2H -> 1560 minutes" $ chk "P1DT2H" (Just (Duration 1560 (Just "minutes")))
|
|
it "PT30M -> 30 minutes" $ chk "PT30M" (Just (Duration 30 (Just "minutes")))
|
|
it "empty string -> Nothing" $ chk "" Nothing
|
|
it "human-readable -> Nothing" $ chk "20 minutes" Nothing
|
|
|
|
describe "schemaOrgToCooklang" $ do
|
|
let loadRecipeFile :: FilePath -> IO (Maybe SchemaOrgRecipe)
|
|
loadRecipeFile path = do
|
|
html <- readFile path
|
|
let decoded = tryDecode (T.pack html)
|
|
pure (parseSchemaOrgRecipe =<< decoded)
|
|
|
|
let countMethodSteps :: Recipe -> Int
|
|
countMethodSteps recipe =
|
|
let sections = toList (recipeSections recipe)
|
|
in case filter (\s -> sectionName s == Just "Method") sections of
|
|
[s] ->
|
|
let bodyItems = toList (sectionBody s)
|
|
in length [() | SecStep _ <- bodyItems]
|
|
_ -> 0
|
|
|
|
let checkStepCount :: FilePath -> String -> Int -> Spec
|
|
checkStepCount path label expectedSteps = it ("preserves " <> label <> " step count in Cooklang conversion") $ do
|
|
mRecipe <- loadRecipeFile path
|
|
case mRecipe of
|
|
Nothing -> fail "Failed to parse schema.org recipe"
|
|
Just r -> do
|
|
let rawInstructions = soRecipeInstructions r
|
|
length rawInstructions `shouldBe` expectedSteps
|
|
case schemaOrgToCooklang r of
|
|
Left err -> fail err
|
|
Right cook -> do
|
|
let cookSteps = countMethodSteps cook
|
|
cookSteps `shouldBe` expectedSteps
|
|
|
|
let checkMetadata :: FilePath -> String -> (SchemaOrgRecipe -> IO ()) -> Spec
|
|
checkMetadata path label checks = it ("converts " <> label <> " schema.org recipe to Cooklang") $ do
|
|
mRecipe <- loadRecipeFile path
|
|
case mRecipe of
|
|
Nothing -> fail "Failed to parse schema.org recipe"
|
|
Just r -> do
|
|
checks r
|
|
case schemaOrgToCooklang r of
|
|
Left err -> fail err
|
|
Right _ -> pure ()
|
|
|
|
checkStepCount "test-data/fried-rice.html" "fried-rice" 4
|
|
|
|
checkStepCount "test-data/carrot-risotto.html" "carrot-risotto" 7
|
|
|
|
checkMetadata "test-data/fried-rice.html" "fried-rice" $ \r ->
|
|
case schemaOrgToCooklang r of
|
|
Left err -> fail err
|
|
Right cook -> do
|
|
metaTitle (recipeMetadata cook) `shouldBe` Just "Fried Rice"
|
|
metaSource (recipeMetadata cook) `shouldBe` Just "https://cooking.nytimes.com/recipes/12177-fried-rice"
|
|
metaCourse (recipeMetadata cook) `shouldBe` Just "one pot, side dish"
|
|
metaCuisine (recipeMetadata cook) `shouldBe` Just "asian"
|
|
metaTotalTime (recipeMetadata cook) `shouldBe` Just (Duration 20 (Just "minutes"))
|
|
metaTags (recipeMetadata cook) `shouldBe` ["egg", "rice", "vegetarian"]
|
|
metaDescription (recipeMetadata cook) `shouldSatisfy` isJust
|
|
let sections = toList (recipeSections cook)
|
|
length sections `shouldBe` 2
|
|
|
|
checkMetadata "test-data/carrot-risotto.html" "carrot-risotto" $ \r ->
|
|
case schemaOrgToCooklang r of
|
|
Left err -> fail err
|
|
Right cook -> do
|
|
metaTitle (recipeMetadata cook) `shouldBe` Just "Carrot Risotto With Chile Crisp"
|
|
metaAuthor (recipeMetadata cook) `shouldBe` Just "Alexa Weibel"
|
|
metaTags (recipeMetadata cook) `shouldSatisfy` (not . null)
|