Files
roux/test/Roux/SchemaOrgSpec.hs
T
jbrechtel c33a51d80b
Build and Deploy / build-and-deploy (push) Successful in 1m14s
test: add step count preservation tests for SchemaOrg → Cooklang conversion
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).
2026-05-20 15:57:14 -04:00

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)