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 "" 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)