Commit: 9543f88
Parent: 896bb52

Parsing to domain recipe

Mårten Åsberg committed on 2026-06-28 at 06:38
src/JsonLdRecipeParser/Amount.cs +36 -0
diff --git a/src/JsonLdRecipeParser/Amount.cs b/src/JsonLdRecipeParser/Amount.cs
index 6d1a801..e3320dd 100644
@@ -3,6 +3,7 @@ using System.Buffers;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Text.RegularExpressions;
using JsonLdRecipeParser.Json;
namespace JsonLdRecipeParser;
@@ -17,6 +18,35 @@ public partial record Amount
public required AmountValue Value { get; set; }
public string? Unit { get; set; }
[return: NotNullIfNotNull(nameof(quantity))]
internal static Amount? From(Quantity? quantity) =>
quantity?.Value switch
{
string q when TryParse(q, out var amount) => amount,
QuantitativeValue q => new()
{
Value = q.Value?.Value switch
{
double v => new ExactAmountValue(v),
string v when TryParseValue(v, out var value) => value,
_ => throw new Exception(),
},
Unit = q.UnitText ?? q.UnitCode,
},
PropertyValue p => new()
{
Value = p.Value?.Value switch
{
double v => new ExactAmountValue(v),
string v when TryParseValue(v, out var value) => value,
_ => throw new Exception(),
},
Unit = p.UnitText ?? p.UnitCode,
},
null => null,
_ => throw new Exception(),
};
public static bool TryParse(string text, [NotNullWhen(true)] out Amount? amount)
{
var span = text.AsSpan();
@@ -37,6 +67,12 @@ public partial record Amount
return true;
}
private static bool TryParseValue(string text, [NotNullWhen(true)] out AmountValue? value)
{
var span = text.AsSpan();
return TryParseValue(ref span, out value);
}
private static bool TryParseValue(ref ReadOnlySpan<char> text, [NotNullWhen(true)] out AmountValue? value)
{
var match = Pattern.Match(text.ToString());
src/JsonLdRecipeParser/Author.cs +11 -0
diff --git a/src/JsonLdRecipeParser/Author.cs b/src/JsonLdRecipeParser/Author.cs
index 412c95c..2b83c8a 100644
@@ -1,6 +1,17 @@
using System;
using JsonLdRecipeParser.Json;
namespace JsonLdRecipeParser;
public class Author
{
public required string Name { get; set; }
internal static Author From(Json.Author author) =>
author.Value switch
{
Person { Name: string name } => new() { Name = name },
Organization { Name: string name } => new() { Name = name },
_ => throw new Exception(),
};
}
src/JsonLdRecipeParser/Ingredient.cs +8 -0
diff --git a/src/JsonLdRecipeParser/Ingredient.cs b/src/JsonLdRecipeParser/Ingredient.cs
index e0bf3b4..36b2548 100644
@@ -1,6 +1,7 @@
using System;
using System.Buffers;
using System.Diagnostics.CodeAnalysis;
using JsonLdRecipeParser.Json;
namespace JsonLdRecipeParser;
@@ -11,6 +12,13 @@ public record Ingredient
public required string Name { get; set; }
public Amount? Amount { get; set; }
internal static Ingredient From(RecipeIngredient ingredient) =>
ingredient.Value switch
{
string text when TryParse(text, out var i) => i,
_ => throw new NotImplementedException("Ingredient parsing is only implemented for strings."),
};
public static bool TryParse(string text, [NotNullWhen(true)] out Ingredient? ingredient)
{
var span = text.AsSpan();
src/JsonLdRecipeParser/Instruction.cs +11 -0
diff --git a/src/JsonLdRecipeParser/Instruction.cs b/src/JsonLdRecipeParser/Instruction.cs
index fd9a9e3..637217d 100644
@@ -1,7 +1,18 @@
using System;
using JsonLdRecipeParser.Json;
namespace JsonLdRecipeParser;
public class Instruction
{
public string? Name { get; set; }
public required string Text { get; set; }
internal static Instruction From(ItemListElement element) =>
element.Value switch
{
HowToStep { Text: string text } step => new() { Name = step.Name, Text = text },
string text => new() { Text = text },
_ => throw new Exception(),
};
}
src/JsonLdRecipeParser/InstructionSection.cs +17 -0
diff --git a/src/JsonLdRecipeParser/InstructionSection.cs b/src/JsonLdRecipeParser/InstructionSection.cs
index 9d26536..55e3401 100644
@@ -1,7 +1,24 @@
using System;
using System.Linq;
using JsonLdRecipeParser.Json;
namespace JsonLdRecipeParser;
public class InstructionSection
{
public string? Name { get; set; }
public Instruction[] Instructions { get; set; } = [];
internal static InstructionSection From(RecipeInstruction instruction) =>
instruction.Value switch
{
HowToStep { Text: string text } step => new() { Instructions = [new() { Name = step.Name, Text = text }] },
ItemList list => new()
{
Name = list.Name,
Instructions = list.ItemListElement is null ? [] : [.. list.ItemListElement.Select(Instruction.From)],
},
string i => new() { Instructions = [new() { Text = i }] },
_ => throw new Exception(),
};
}
src/JsonLdRecipeParser/Nutrients.cs +20 -0
diff --git a/src/JsonLdRecipeParser/Nutrients.cs b/src/JsonLdRecipeParser/Nutrients.cs
index 3a93c9e..14fb3f0 100644
@@ -1,3 +1,6 @@
using System;
using JsonLdRecipeParser.Json;
namespace JsonLdRecipeParser;
public class Nutrients
@@ -14,4 +17,21 @@ public class Nutrients
public Amount? Sugar { get; set; }
public Amount? TransFat { get; set; }
public Amount? UnsaturatedFat { get; set; }
internal static Nutrients From(NutritionInformation nutrition) =>
new()
{
ServingSize = Amount.From(nutrition.ServingSize),
Calories = Amount.From(nutrition.Calories),
Carbohydrate = Amount.From(nutrition.CarbohydrateContent),
Cholesterol = Amount.From(nutrition.CholesterolContent),
Fat = Amount.From(nutrition.FatContent),
Fiber = Amount.From(nutrition.FiberContent),
Protein = Amount.From(nutrition.ProteinContent),
SaturatedFat = Amount.From(nutrition.SaturatedFatContent),
Sodium = Amount.From(nutrition.SodiumContent),
Sugar = Amount.From(nutrition.SugarContent),
TransFat = Amount.From(nutrition.TransFatContent),
UnsaturatedFat = Amount.From(nutrition.UnsaturatedFatContent),
};
}
src/JsonLdRecipeParser/Recipe.cs +51 -0
diff --git a/src/JsonLdRecipeParser/Recipe.cs b/src/JsonLdRecipeParser/Recipe.cs
index bf3d173..10b9215 100644
@@ -1,4 +1,7 @@
using System;
using System.Linq;
using System.Text.Json;
using JsonLdRecipeParser.Json;
namespace JsonLdRecipeParser;
@@ -16,4 +19,52 @@ public class Recipe
public Amount? Yield { get; set; }
public string[] Category { get; set; } = [];
public string[] Cuisine { get; set; } = [];
public static Recipe Parse(JsonDocument json)
{
var result = JsonSerializer.Deserialize(json, JsonLdSerializerContext.Default.JsonLd);
return result?.Value switch
{
Json.Recipe recipe => From(recipe),
Thing[] things when things.OfType<Json.Recipe>().FirstOrDefault() is Json.Recipe recipe => From(recipe),
_ => throw new Exception(),
};
}
internal static Recipe From(Json.Recipe recipe) =>
new()
{
Name = recipe.Name ?? throw new Exception(),
Description = recipe.Description,
Authors = recipe.Author?.Value switch
{
Json.Author author => [Author.From(author)],
Json.Author[] authors => [.. authors.Select(Author.From)],
null => [],
_ => throw new Exception(),
},
TotalTime = recipe.TotalTime,
PrepTime = recipe.PrepTime,
CookTime = recipe.CookTime,
Ingredients = recipe.RecipeIngredient is null ? [] : [.. recipe.RecipeIngredient.Select(Ingredient.From)],
InstructionSections = recipe.RecipeInstructions is null
? []
: [.. recipe.RecipeInstructions.Select(InstructionSection.From)],
Nutrients = recipe.Nutrition is null ? null : Nutrients.From(recipe.Nutrition),
Yield = recipe.RecipeYield is null ? null : Amount.From(recipe.RecipeYield),
Category = recipe.RecipeCategory?.Value switch
{
string category => [category],
string[] categories => categories,
null => [],
_ => throw new Exception(),
},
Cuisine = recipe.RecipeCuisine?.Value switch
{
string cuisine => [cuisine],
string[] cuisines => cuisines,
null => [],
_ => throw new Exception(),
},
};
}
test/JsonLdRecipeParser.Tests/AmountTests/FromShould.cs +109 -0
diff --git a/test/JsonLdRecipeParser.Tests/AmountTests/FromShould.cs b/test/JsonLdRecipeParser.Tests/AmountTests/FromShould.cs
new file mode 100644
index 0000000..a903e53
@@ -0,0 +1,109 @@
using JsonLdRecipeParser.Json;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Shouldly;
namespace JsonLdRecipeParser.Tests.AmountTests;
[TestClass]
public class FromShould
{
[TestMethod]
public void MapAStringQuantity()
{
// Arrange
var quantity = new Quantity { Value = "4 servings" };
// Act
var result = Amount.From(quantity);
// Assert
result.ShouldBeEquivalentTo(new Amount { Value = new ExactAmountValue(4.0d), Unit = "servings" });
}
[TestMethod]
public void MapAQuantitativeValueWithDoubleValue()
{
// Arrange
var quantity = new Quantity { Value = new QuantitativeValue { Value = new ValueProperty { Value = 400.0d } } };
// Act
var result = Amount.From(quantity);
// Assert
result.ShouldBeEquivalentTo(new Amount { Value = new ExactAmountValue(400.0d), Unit = null });
}
[TestMethod]
public void MapAQuantitativeValueWithStringValue()
{
// Arrange
var quantity = new Quantity { Value = new QuantitativeValue { Value = new ValueProperty { Value = "2.5" } } };
// Act
var result = Amount.From(quantity);
// Assert
result.ShouldBeEquivalentTo(new Amount { Value = new ExactAmountValue(2.5d), Unit = null });
}
[TestMethod]
public void MapAQuantitativeValueWithUnitText()
{
// Arrange
var quantity = new Quantity
{
Value = new QuantitativeValue
{
Value = new ValueProperty { Value = 400.0d },
UnitText = "ml",
},
};
// Act
var result = Amount.From(quantity);
// Assert
result.ShouldBeEquivalentTo(new Amount { Value = new ExactAmountValue(400.0d), Unit = "ml" });
}
[TestMethod]
public void MapAQuantitativeValueWithUnitCodeWhenUnitTextIsMissing()
{
// Arrange
var quantity = new Quantity
{
Value = new QuantitativeValue
{
Value = new ValueProperty { Value = 250.0d },
UnitCode = "GRM",
},
};
// Act
var result = Amount.From(quantity);
// Assert
result.ShouldBeEquivalentTo(new Amount { Value = new ExactAmountValue(250.0d), Unit = "GRM" });
}
[TestMethod]
public void PreferUnitTextOverUnitCode()
{
// Arrange
var quantity = new Quantity
{
Value = new QuantitativeValue
{
Value = new ValueProperty { Value = 10.0d },
UnitText = "dl",
UnitCode = "DLT",
},
};
// Act
var result = Amount.From(quantity);
// Assert
result.ShouldBeEquivalentTo(new Amount { Value = new ExactAmountValue(10.0d), Unit = "dl" });
}
}
test/JsonLdRecipeParser.Tests/AuthorTests/FromShould.cs +35 -0
diff --git a/test/JsonLdRecipeParser.Tests/AuthorTests/FromShould.cs b/test/JsonLdRecipeParser.Tests/AuthorTests/FromShould.cs
new file mode 100644
index 0000000..9f6f145
@@ -0,0 +1,35 @@
using JsonLdRecipeParser.Json;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Shouldly;
namespace JsonLdRecipeParser.Tests.AuthorTests;
[TestClass]
public class FromShould
{
[TestMethod]
public void MapAPersonAuthor()
{
// Arrange
var author = new JsonLdRecipeParser.Json.Author { Value = new Person { Name = "Mary Stone" } };
// Act
var result = JsonLdRecipeParser.Author.From(author);
// Assert
result.ShouldBeEquivalentTo(new JsonLdRecipeParser.Author { Name = "Mary Stone" });
}
[TestMethod]
public void MapAnOrganizationAuthor()
{
// Arrange
var author = new JsonLdRecipeParser.Json.Author { Value = new Organization { Name = "ACME Publishing" } };
// Act
var result = JsonLdRecipeParser.Author.From(author);
// Assert
result.ShouldBeEquivalentTo(new JsonLdRecipeParser.Author { Name = "ACME Publishing" });
}
}
test/JsonLdRecipeParser.Tests/IngredientTests/FromShould.cs +28 -0
diff --git a/test/JsonLdRecipeParser.Tests/IngredientTests/FromShould.cs b/test/JsonLdRecipeParser.Tests/IngredientTests/FromShould.cs
new file mode 100644
index 0000000..0db6e80
@@ -0,0 +1,28 @@
using JsonLdRecipeParser.Json;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Shouldly;
namespace JsonLdRecipeParser.Tests.IngredientTests;
[TestClass]
public class FromShould
{
[TestMethod]
public void MapAStringRecipeIngredient()
{
// Arrange
var ingredient = new RecipeIngredient { Value = "12g sugar" };
// Act
var result = Ingredient.From(ingredient);
// Assert
result.ShouldBeEquivalentTo(
new Ingredient
{
Name = "sugar",
Amount = new Amount { Value = new ExactAmountValue(12.0d), Unit = "g" },
}
);
}
}
test/JsonLdRecipeParser.Tests/InstructionSectionTests/FromShould.cs +173 -0
diff --git a/test/JsonLdRecipeParser.Tests/InstructionSectionTests/FromShould.cs b/test/JsonLdRecipeParser.Tests/InstructionSectionTests/FromShould.cs
new file mode 100644
index 0000000..5e85c98
@@ -0,0 +1,173 @@
using JsonLdRecipeParser.Json;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Shouldly;
namespace JsonLdRecipeParser.Tests.InstructionSectionTests;
[TestClass]
public class FromShould
{
[TestMethod]
public void MapAStringRecipeInstruction()
{
// Arrange
var instruction = new RecipeInstruction { Value = "Mix well and serve." };
// Act
var result = InstructionSection.From(instruction);
// Assert
result.ShouldBeEquivalentTo(
new InstructionSection { Instructions = [new Instruction { Text = "Mix well and serve." }] }
);
}
[TestMethod]
public void MapAHowToStepRecipeInstruction()
{
// Arrange
var instruction = new RecipeInstruction
{
Value = new HowToStep { Name = "Blend", Text = "Blend until smooth." },
};
// Act
var result = InstructionSection.From(instruction);
// Assert
result.ShouldBeEquivalentTo(
new InstructionSection { Instructions = [new Instruction { Name = "Blend", Text = "Blend until smooth." }] }
);
}
[TestMethod]
public void MapAHowToSectionRecipeInstructionWithStringSteps()
{
// Arrange
var instruction = new RecipeInstruction
{
Value = new HowToSection
{
Name = "Prepare the batter",
ItemListElement =
[
new ItemListElement { Value = "Sift the flour." },
new ItemListElement { Value = "Add the eggs." },
],
},
};
// Act
var result = InstructionSection.From(instruction);
// Assert
result.ShouldBeEquivalentTo(
new InstructionSection
{
Name = "Prepare the batter",
Instructions =
[
new Instruction { Text = "Sift the flour." },
new Instruction { Text = "Add the eggs." },
],
}
);
}
[TestMethod]
public void MapAHowToSectionRecipeInstructionWithHowToStepElements()
{
// Arrange
var instruction = new RecipeInstruction
{
Value = new HowToSection
{
Name = "Cook",
ItemListElement =
[
new ItemListElement
{
Value = new HowToStep { Name = "Heat", Text = "Heat the pan." },
},
new ItemListElement
{
Value = new HowToStep { Name = "Fry", Text = "Fry until golden." },
},
],
},
};
// Act
var result = InstructionSection.From(instruction);
// Assert
result.ShouldBeEquivalentTo(
new InstructionSection
{
Name = "Cook",
Instructions =
[
new Instruction { Name = "Heat", Text = "Heat the pan." },
new Instruction { Name = "Fry", Text = "Fry until golden." },
],
}
);
}
[TestMethod]
public void MapAnItemListRecipeInstructionWithStringElements()
{
// Arrange
var instruction = new RecipeInstruction
{
Value = new ItemList
{
Name = "Steps",
ItemListElement =
[
new ItemListElement { Value = "Step one." },
new ItemListElement { Value = "Step two." },
],
},
};
// Act
var result = InstructionSection.From(instruction);
// Assert
result.ShouldBeEquivalentTo(
new InstructionSection
{
Name = "Steps",
Instructions = [new Instruction { Text = "Step one." }, new Instruction { Text = "Step two." }],
}
);
}
[TestMethod]
public void MapAnItemListRecipeInstructionWithNestedHowToStepElements()
{
// Arrange
var instruction = new RecipeInstruction
{
Value = new ItemList
{
ItemListElement =
[
new ItemListElement
{
Value = new HowToStep { Name = "Mix", Text = "Mix ingredients." },
},
],
},
};
// Act
var result = InstructionSection.From(instruction);
// Assert
result.ShouldBeEquivalentTo(
new InstructionSection { Instructions = [new Instruction { Name = "Mix", Text = "Mix ingredients." }] }
);
}
}
test/JsonLdRecipeParser.Tests/Json/JsonLdSerializerContextTests/DeserializeShould.cs +2 -87
diff --git a/test/JsonLdRecipeParser.Tests/Json/JsonLdSerializerContextTests/DeserializeShould.cs b/test/JsonLdRecipeParser.Tests/Json/JsonLdSerializerContextTests/DeserializeShould.cs
index 3197c8f..9eea7b1 100644
@@ -9,91 +9,6 @@ namespace JsonLdRecipeParser.Tests.Json.JsonLdSerializerContextTests;
[TestClass]
public class DeserializeShould
{
const string googleExample = """
{
"@context": "https://schema.org/",
"@type": "Recipe",
"name": "Non-Alcoholic Piña Colada",
"image": [
"https://example.com/photos/1x1/photo.jpg",
"https://example.com/photos/4x3/photo.jpg",
"https://example.com/photos/16x9/photo.jpg"
],
"author": {
"@type": "Person",
"name": "Mary Stone"
},
"datePublished": "2018-03-10",
"description": "This non-alcoholic pina colada is everyone's favorite!",
"recipeCuisine": "American",
"prepTime": "PT1M",
"cookTime": "PT2M",
"totalTime": "PT3M",
"keywords": "non-alcoholic",
"recipeYield": "4 servings",
"recipeCategory": "Drink",
"nutrition": {
"@type": "NutritionInformation",
"calories": "120 calories"
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "5",
"ratingCount": "18"
},
"recipeIngredient": [
"400ml of pineapple juice",
"100ml cream of coconut",
"ice"
],
"recipeInstructions": [
{
"@type": "HowToStep",
"name": "Blend",
"text": "Blend 400ml of pineapple juice and 100ml cream of coconut until smooth.",
"url": "https://example.com/non-alcoholic-pina-colada#step1",
"image": "https://example.com/photos/non-alcoholic-pina-colada/step1.jpg"
},
{
"@type": "HowToStep",
"name": "Fill",
"text": "Fill a glass with ice.",
"url": "https://example.com/non-alcoholic-pina-colada#step2",
"image": "https://example.com/photos/non-alcoholic-pina-colada/step2.jpg"
},
{
"@type": "HowToStep",
"name": "Pour",
"text": "Pour the pineapple juice and coconut mixture over ice.",
"url": "https://example.com/non-alcoholic-pina-colada#step3",
"image": "https://example.com/photos/non-alcoholic-pina-colada/step3.jpg"
}
],
"video": {
"@type": "VideoObject",
"name": "How to Make a Non-Alcoholic Piña Colada",
"description": "This is how you make a non-alcoholic piña colada.",
"thumbnailUrl": [
"https://example.com/photos/1x1/photo.jpg",
"https://example.com/photos/4x3/photo.jpg",
"https://example.com/photos/16x9/photo.jpg"
],
"contentUrl": "https://www.example.com/video123.mp4",
"embedUrl": "https://www.example.com/videoplayer?video=123",
"uploadDate": "2018-02-05T08:00:00+08:00",
"duration": "PT1M33S",
"interactionStatistic": {
"@type": "InteractionCounter",
"interactionType": {
"@type": "WatchAction"
},
"userInteractionCount": 2347
},
"expires": "2019-02-05T08:00:00+08:00"
}
}
""";
[TestMethod]
public void DeserializeAnEmptyArray()
{
@@ -113,7 +28,7 @@ public class DeserializeShould
public void DeserializeJsonLdFromGoogleExample()
{
// Act
var result = JsonSerializer.Deserialize(googleExample, JsonLdSerializerContext.Default.JsonLd);
var result = JsonSerializer.Deserialize(TestData.GoogleExample, JsonLdSerializerContext.Default.JsonLd);
// Assert
result.ShouldNotBeNull();
@@ -171,7 +86,7 @@ public class DeserializeShould
public void DeserializeJsonLdFromGoogleExampleInArray()
{
// Arrange
const string json = $"[{googleExample}]";
const string json = $"[{TestData.GoogleExample}]";
// Act
var result = JsonSerializer.Deserialize(json, JsonLdSerializerContext.Default.JsonLd);
test/JsonLdRecipeParser.Tests/NutrientsTests/FromShould.cs +109 -0
diff --git a/test/JsonLdRecipeParser.Tests/NutrientsTests/FromShould.cs b/test/JsonLdRecipeParser.Tests/NutrientsTests/FromShould.cs
new file mode 100644
index 0000000..ad67a1d
@@ -0,0 +1,109 @@
using JsonLdRecipeParser.Json;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Shouldly;
namespace JsonLdRecipeParser.Tests.NutrientsTests;
[TestClass]
public class FromShould
{
[TestMethod]
public void MapStringQuantities()
{
// Arrange
var nutrition = new NutritionInformation
{
Calories = new Quantity { Value = "120 calories" },
CarbohydrateContent = new Quantity { Value = "10 g" },
CholesterolContent = new Quantity { Value = "0 mg" },
FatContent = new Quantity { Value = "5 g" },
FiberContent = new Quantity { Value = "2 g" },
ProteinContent = new Quantity { Value = "3 g" },
SaturatedFatContent = new Quantity { Value = "1 g" },
ServingSize = new Quantity { Value = "1 cup" },
SodiumContent = new Quantity { Value = "200 mg" },
SugarContent = new Quantity { Value = "4 g" },
TransFatContent = new Quantity { Value = "0 g" },
UnsaturatedFatContent = new Quantity { Value = "4 g" },
};
// Act
var result = Nutrients.From(nutrition);
// Assert
result.ShouldBeEquivalentTo(
new Nutrients
{
Calories = new Amount { Value = new ExactAmountValue(120.0d), Unit = "calories" },
Carbohydrate = new Amount { Value = new ExactAmountValue(10.0d), Unit = "g" },
Cholesterol = new Amount { Value = new ExactAmountValue(0.0d), Unit = "mg" },
Fat = new Amount { Value = new ExactAmountValue(5.0d), Unit = "g" },
Fiber = new Amount { Value = new ExactAmountValue(2.0d), Unit = "g" },
Protein = new Amount { Value = new ExactAmountValue(3.0d), Unit = "g" },
SaturatedFat = new Amount { Value = new ExactAmountValue(1.0d), Unit = "g" },
ServingSize = new Amount { Value = new ExactAmountValue(1.0d), Unit = "cup" },
Sodium = new Amount { Value = new ExactAmountValue(200.0d), Unit = "mg" },
Sugar = new Amount { Value = new ExactAmountValue(4.0d), Unit = "g" },
TransFat = new Amount { Value = new ExactAmountValue(0.0d), Unit = "g" },
UnsaturatedFat = new Amount { Value = new ExactAmountValue(4.0d), Unit = "g" },
}
);
}
[TestMethod]
public void MapQuantitativeValueQuantities()
{
// Arrange
var nutrition = new NutritionInformation
{
Calories = new Quantity
{
Value = new QuantitativeValue
{
Value = new ValueProperty { Value = 120.0d },
UnitText = "calories",
},
},
};
// Act
var result = Nutrients.From(nutrition);
// Assert
result.ShouldBeEquivalentTo(
new Nutrients
{
Calories = new Amount { Value = new ExactAmountValue(120.0d), Unit = "calories" },
}
);
}
[TestMethod]
public void MapNullNutrientFields()
{
// Arrange
var nutrition = new NutritionInformation { Calories = new Quantity { Value = "120 calories" } };
// Act
var result = Nutrients.From(nutrition);
// Assert
result.ShouldBeEquivalentTo(
new Nutrients
{
Calories = new Amount { Value = new ExactAmountValue(120.0d), Unit = "calories" },
}
);
result.Carbohydrate.ShouldBeNull();
result.Cholesterol.ShouldBeNull();
result.Fat.ShouldBeNull();
result.Fiber.ShouldBeNull();
result.Protein.ShouldBeNull();
result.SaturatedFat.ShouldBeNull();
result.ServingSize.ShouldBeNull();
result.Sodium.ShouldBeNull();
result.Sugar.ShouldBeNull();
result.TransFat.ShouldBeNull();
result.UnsaturatedFat.ShouldBeNull();
}
}
test/JsonLdRecipeParser.Tests/RecipeTests/FromShould.cs +334 -0
diff --git a/test/JsonLdRecipeParser.Tests/RecipeTests/FromShould.cs b/test/JsonLdRecipeParser.Tests/RecipeTests/FromShould.cs
new file mode 100644
index 0000000..5394870
@@ -0,0 +1,334 @@
using System;
using JsonLdRecipeParser.Json;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Shouldly;
namespace JsonLdRecipeParser.Tests.RecipeTests;
[TestClass]
public class FromShould
{
[TestMethod]
public void MapNameAndDescription()
{
// Arrange
var recipe = new JsonLdRecipeParser.Json.Recipe
{
Name = "Chocolate Cake",
Description = "A rich and moist cake.",
};
// Act
var result = Recipe.From(recipe);
// Assert
result.Name.ShouldBe("Chocolate Cake");
result.Description.ShouldBe("A rich and moist cake.");
}
[TestMethod]
public void MapASingleAuthor()
{
// Arrange
var recipe = new JsonLdRecipeParser.Json.Recipe
{
Name = "Test Recipe",
Author = new Authors
{
Value = new JsonLdRecipeParser.Json.Author { Value = new Person { Name = "Mary Stone" } },
},
};
// Act
var result = Recipe.From(recipe);
// Assert
result.Authors.Length.ShouldBe(1);
result.Authors[0].Name.ShouldBe("Mary Stone");
}
[TestMethod]
public void MapMultipleAuthors()
{
// Arrange
var recipe = new JsonLdRecipeParser.Json.Recipe
{
Name = "Test Recipe",
Author = new Authors
{
Value = new JsonLdRecipeParser.Json.Author[]
{
new() { Value = new Person { Name = "Mary Stone" } },
new() { Value = new Person { Name = "John Doe" } },
},
},
};
// Act
var result = Recipe.From(recipe);
// Assert
result.Authors.Length.ShouldBe(2);
result.Authors[0].Name.ShouldBe("Mary Stone");
result.Authors[1].Name.ShouldBe("John Doe");
}
[TestMethod]
public void MapNoAuthors()
{
// Arrange
var recipe = new JsonLdRecipeParser.Json.Recipe { Name = "Test Recipe" };
// Act
var result = Recipe.From(recipe);
// Assert
result.Authors.ShouldBeEmpty();
}
[TestMethod]
public void MapTimes()
{
// Arrange
var recipe = new JsonLdRecipeParser.Json.Recipe
{
Name = "Test Recipe",
PrepTime = TimeSpan.FromMinutes(10),
CookTime = TimeSpan.FromMinutes(30),
TotalTime = TimeSpan.FromMinutes(40),
};
// Act
var result = Recipe.From(recipe);
// Assert
result.PrepTime.ShouldBe(TimeSpan.FromMinutes(10));
result.CookTime.ShouldBe(TimeSpan.FromMinutes(30));
result.TotalTime.ShouldBe(TimeSpan.FromMinutes(40));
}
[TestMethod]
public void MapCategoryFromString()
{
// Arrange
var recipe = new JsonLdRecipeParser.Json.Recipe
{
Name = "Test Recipe",
RecipeCategory = new RecipeCategories { Value = "Dessert" },
};
// Act
var result = Recipe.From(recipe);
// Assert
result.Category.ShouldBe(["Dessert"]);
}
[TestMethod]
public void MapCategoryFromStringArray()
{
// Arrange
var recipe = new JsonLdRecipeParser.Json.Recipe
{
Name = "Test Recipe",
RecipeCategory = new RecipeCategories { Value = new[] { "Dessert", "Cake" } },
};
// Act
var result = Recipe.From(recipe);
// Assert
result.Category.ShouldBe(["Dessert", "Cake"]);
}
[TestMethod]
public void MapNoCategory()
{
// Arrange
var recipe = new JsonLdRecipeParser.Json.Recipe { Name = "Test Recipe" };
// Act
var result = Recipe.From(recipe);
// Assert
result.Category.ShouldBeEmpty();
}
[TestMethod]
public void MapCuisineFromString()
{
// Arrange
var recipe = new JsonLdRecipeParser.Json.Recipe
{
Name = "Test Recipe",
RecipeCuisine = new RecipeCuisines { Value = "American" },
};
// Act
var result = Recipe.From(recipe);
// Assert
result.Cuisine.ShouldBe(["American"]);
}
[TestMethod]
public void MapCuisineFromStringArray()
{
// Arrange
var recipe = new JsonLdRecipeParser.Json.Recipe
{
Name = "Test Recipe",
RecipeCuisine = new RecipeCuisines { Value = new[] { "American", "Southern" } },
};
// Act
var result = Recipe.From(recipe);
// Assert
result.Cuisine.ShouldBe(["American", "Southern"]);
}
[TestMethod]
public void MapNoCuisine()
{
// Arrange
var recipe = new JsonLdRecipeParser.Json.Recipe { Name = "Test Recipe" };
// Act
var result = Recipe.From(recipe);
// Assert
result.Cuisine.ShouldBeEmpty();
}
[TestMethod]
public void MapNullNutrition()
{
// Arrange
var recipe = new JsonLdRecipeParser.Json.Recipe { Name = "Test Recipe" };
// Act
var result = Recipe.From(recipe);
// Assert
result.Nutrients.ShouldBeNull();
}
[TestMethod]
public void MapNullYield()
{
// Arrange
var recipe = new JsonLdRecipeParser.Json.Recipe { Name = "Test Recipe" };
// Act
var result = Recipe.From(recipe);
// Assert
result.Yield.ShouldBeNull();
}
[TestMethod]
public void MapNullIngredients()
{
// Arrange
var recipe = new JsonLdRecipeParser.Json.Recipe { Name = "Test Recipe" };
// Act
var result = Recipe.From(recipe);
// Assert
result.Ingredients.ShouldBeEmpty();
}
[TestMethod]
public void MapNullInstructions()
{
// Arrange
var recipe = new JsonLdRecipeParser.Json.Recipe { Name = "Test Recipe" };
// Act
var result = Recipe.From(recipe);
// Assert
result.InstructionSections.ShouldBeEmpty();
}
[TestMethod]
public void PassThroughIngredients()
{
// Arrange
var recipe = new JsonLdRecipeParser.Json.Recipe
{
Name = "Test Recipe",
RecipeIngredient = [new RecipeIngredient { Value = "sugar" }, new RecipeIngredient { Value = "flour" }],
};
// Act
var result = Recipe.From(recipe);
// Assert
result.Ingredients.Length.ShouldBe(2);
result.Ingredients[0].Name.ShouldBe("sugar");
result.Ingredients[1].Name.ShouldBe("flour");
}
[TestMethod]
public void PassThroughInstructions()
{
// Arrange
var recipe = new JsonLdRecipeParser.Json.Recipe
{
Name = "Test Recipe",
RecipeInstructions =
[
new RecipeInstruction { Value = "Mix ingredients." },
new RecipeInstruction { Value = "Bake for 30 minutes." },
],
};
// Act
var result = Recipe.From(recipe);
// Assert
result.InstructionSections.Length.ShouldBe(2);
result.InstructionSections[0].Instructions[0].Text.ShouldBe("Mix ingredients.");
result.InstructionSections[1].Instructions[0].Text.ShouldBe("Bake for 30 minutes.");
}
[TestMethod]
public void PassThroughNutrition()
{
// Arrange
var recipe = new JsonLdRecipeParser.Json.Recipe
{
Name = "Test Recipe",
Nutrition = new NutritionInformation { Calories = new Quantity { Value = "120 calories" } },
};
// Act
var result = Recipe.From(recipe);
// Assert
result.Nutrients.ShouldNotBeNull();
result.Nutrients!.Calories!.Value.ShouldBe(new ExactAmountValue(120.0d));
}
[TestMethod]
public void PassThroughYield()
{
// Arrange
var recipe = new JsonLdRecipeParser.Json.Recipe
{
Name = "Test Recipe",
RecipeYield = new Quantity { Value = "4 servings" },
};
// Act
var result = Recipe.From(recipe);
// Assert
result.Yield.ShouldNotBeNull();
result.Yield!.Value.ShouldBe(new ExactAmountValue(4.0d));
result.Yield.Unit.ShouldBe("servings");
}
}
test/JsonLdRecipeParser.Tests/RecipeTests/ParseShould.cs +92 -0
diff --git a/test/JsonLdRecipeParser.Tests/RecipeTests/ParseShould.cs b/test/JsonLdRecipeParser.Tests/RecipeTests/ParseShould.cs
new file mode 100644
index 0000000..e175234
@@ -0,0 +1,92 @@
using System;
using System.Text.Json;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Shouldly;
namespace JsonLdRecipeParser.Tests.RecipeTests;
[TestClass]
public class ParseShould
{
[TestMethod]
public void ParseJsonLdFromGoogleExample()
{
// Arrange
var document = JsonSerializer.Deserialize<JsonDocument>(TestData.GoogleExample)!;
// Act
var result = Recipe.Parse(document);
// Assert
result.ShouldBeEquivalentTo(expectedGoogleExampleRecipe);
}
[TestMethod]
public void ParseJsonLdFromGoogleExampleInArray()
{
// Arrange
var document = JsonSerializer.Deserialize<JsonDocument>($"[{TestData.GoogleExample}]")!;
// Act
var result = Recipe.Parse(document);
// Assert
result.ShouldBeEquivalentTo(expectedGoogleExampleRecipe);
}
private static readonly Recipe expectedGoogleExampleRecipe = new()
{
Name = "Non-Alcoholic Piña Colada",
Description = "This non-alcoholic pina colada is everyone's favorite!",
Authors = [new Author { Name = "Mary Stone" }],
PrepTime = TimeSpan.FromMinutes(1),
TotalTime = TimeSpan.FromMinutes(3),
CookTime = TimeSpan.FromMinutes(2),
Nutrients = new Nutrients
{
Calories = new Amount { Value = new ExactAmountValue(120.0d), Unit = "calories" },
},
Category = ["Drink"],
Cuisine = ["American"],
Ingredients =
[
new Ingredient
{
Name = "of",
Amount = new Amount { Value = new ExactAmountValue(400.0d), Unit = "ml" },
},
new Ingredient
{
Name = "cream",
Amount = new Amount { Value = new ExactAmountValue(100.0d), Unit = "ml" },
},
new Ingredient { Name = "ice" },
],
InstructionSections =
[
new InstructionSection
{
Instructions =
[
new Instruction
{
Name = "Blend",
Text = "Blend 400ml of pineapple juice and 100ml cream of coconut until smooth.",
},
],
},
new InstructionSection
{
Instructions = [new Instruction { Name = "Fill", Text = "Fill a glass with ice." }],
},
new InstructionSection
{
Instructions =
[
new Instruction { Name = "Pour", Text = "Pour the pineapple juice and coconut mixture over ice." },
],
},
],
Yield = new Amount { Value = new ExactAmountValue(4.0d), Unit = "servings" },
};
}
test/JsonLdRecipeParser.Tests/TestData.cs +89 -0
diff --git a/test/JsonLdRecipeParser.Tests/TestData.cs b/test/JsonLdRecipeParser.Tests/TestData.cs
new file mode 100644
index 0000000..3ab1fc0
@@ -0,0 +1,89 @@
namespace JsonLdRecipeParser.Tests;
internal static class TestData
{
internal const string GoogleExample = """
{
"@context": "https://schema.org/",
"@type": "Recipe",
"name": "Non-Alcoholic Piña Colada",
"image": [
"https://example.com/photos/1x1/photo.jpg",
"https://example.com/photos/4x3/photo.jpg",
"https://example.com/photos/16x9/photo.jpg"
],
"author": {
"@type": "Person",
"name": "Mary Stone"
},
"datePublished": "2018-03-10",
"description": "This non-alcoholic pina colada is everyone's favorite!",
"recipeCuisine": "American",
"prepTime": "PT1M",
"cookTime": "PT2M",
"totalTime": "PT3M",
"keywords": "non-alcoholic",
"recipeYield": "4 servings",
"recipeCategory": "Drink",
"nutrition": {
"@type": "NutritionInformation",
"calories": "120 calories"
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "5",
"ratingCount": "18"
},
"recipeIngredient": [
"400ml of pineapple juice",
"100ml cream of coconut",
"ice"
],
"recipeInstructions": [
{
"@type": "HowToStep",
"name": "Blend",
"text": "Blend 400ml of pineapple juice and 100ml cream of coconut until smooth.",
"url": "https://example.com/non-alcoholic-pina-colada#step1",
"image": "https://example.com/photos/non-alcoholic-pina-colada/step1.jpg"
},
{
"@type": "HowToStep",
"name": "Fill",
"text": "Fill a glass with ice.",
"url": "https://example.com/non-alcoholic-pina-colada#step2",
"image": "https://example.com/photos/non-alcoholic-pina-colada/step2.jpg"
},
{
"@type": "HowToStep",
"name": "Pour",
"text": "Pour the pineapple juice and coconut mixture over ice.",
"url": "https://example.com/non-alcoholic-pina-colada#step3",
"image": "https://example.com/photos/non-alcoholic-pina-colada/step3.jpg"
}
],
"video": {
"@type": "VideoObject",
"name": "How to Make a Non-Alcoholic Piña Colada",
"description": "This is how you make a non-alcoholic piña colada.",
"thumbnailUrl": [
"https://example.com/photos/1x1/photo.jpg",
"https://example.com/photos/4x3/photo.jpg",
"https://example.com/photos/16x9/photo.jpg"
],
"contentUrl": "https://www.example.com/video123.mp4",
"embedUrl": "https://www.example.com/videoplayer?video=123",
"uploadDate": "2018-02-05T08:00:00+08:00",
"duration": "PT1M33S",
"interactionStatistic": {
"@type": "InteractionCounter",
"interactionType": {
"@type": "WatchAction"
},
"userInteractionCount": 2347
},
"expires": "2019-02-05T08:00:00+08:00"
}
}
""";
}