cooklang 1.0.3 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +23 -7
- data/lib/cooklang/formatter.rb +0 -46
- data/lib/cooklang/formatters/hash.rb +4 -2
- data/lib/cooklang/formatters/text.rb +46 -0
- data/lib/cooklang/ingredient.rb +12 -5
- data/lib/cooklang/lexer.rb +53 -4
- data/lib/cooklang/metadata.rb +0 -64
- data/lib/cooklang/parsers/ingredient_parser.rb +10 -3
- data/lib/cooklang/processors/metadata_processor.rb +15 -35
- data/lib/cooklang/processors/step_processor.rb +1 -1
- data/lib/cooklang/version.rb +1 -1
- data/spec/fixtures/comprehensive_recipe.cook +51 -0
- data/spec/formatters/formatter_spec.rb +29 -0
- data/spec/formatters/hash_spec.rb +75 -0
- data/spec/formatters/json_spec.rb +55 -0
- data/spec/formatters/text_spec.rb +30 -168
- data/spec/integration/metadata_canonical_spec.rb +169 -0
- data/spec/lexer_spec.rb +4 -4
- data/spec/models/ingredient_spec.rb +59 -3
- data/spec/models/metadata_spec.rb +19 -149
- data/spec/models/recipe_spec.rb +2 -2
- data/spec/parser_spec.rb +67 -0
- metadata +12 -2
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Cooklang::Formatters::Hash do
|
|
6
|
+
describe "#generate" do
|
|
7
|
+
let(:recipe) do
|
|
8
|
+
recipe_text = File.read(File.join(__dir__, "..", "fixtures", "comprehensive_recipe.cook"))
|
|
9
|
+
Cooklang.parse(recipe_text)
|
|
10
|
+
end
|
|
11
|
+
let(:result) { described_class.new(recipe).generate }
|
|
12
|
+
|
|
13
|
+
it "generates hash with all required top-level keys" do
|
|
14
|
+
expect(result).to have_key(:metadata)
|
|
15
|
+
expect(result).to have_key(:sections)
|
|
16
|
+
expect(result).to have_key(:ingredients)
|
|
17
|
+
expect(result).to have_key(:cookware)
|
|
18
|
+
expect(result).to have_key(:timers)
|
|
19
|
+
expect(result).to have_key(:data)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it "includes expected metadata" do
|
|
23
|
+
metadata = result[:metadata][:map]
|
|
24
|
+
expect(metadata["title"]).to eq("Uncle Roger's Ultimate Fried Rice & Dumplings")
|
|
25
|
+
expect(metadata["servings"]).to eq(4)
|
|
26
|
+
expect(metadata["difficulty"]).to eq("intermediate")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it "parses ingredients with decimal quantities and nil quantities" do
|
|
30
|
+
ingredients = result[:ingredients]
|
|
31
|
+
|
|
32
|
+
# Find MSG with decimal quantity
|
|
33
|
+
msg = ingredients.find { |i| i[:name] == "msg" }
|
|
34
|
+
expect(msg[:quantity][:value][:value][:value]).to eq(0.5)
|
|
35
|
+
|
|
36
|
+
# Find ingredient with nil quantity (like "some")
|
|
37
|
+
white_pepper = ingredients.find { |i| i[:name] == "white pepper" }
|
|
38
|
+
expect(white_pepper[:quantity]).to be_nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it "includes timers with named and decimal durations" do
|
|
42
|
+
timers = result[:timers]
|
|
43
|
+
expect(timers.size).to be >= 5
|
|
44
|
+
|
|
45
|
+
# Find named timer
|
|
46
|
+
named_timer = timers.find { |t| t[:name] == "quick scramble" }
|
|
47
|
+
expect(named_timer).not_to be_nil
|
|
48
|
+
|
|
49
|
+
# Find decimal duration timer
|
|
50
|
+
decimal_timer = timers.find { |t| t[:quantity][:value][:value][:value] == 2.5 }
|
|
51
|
+
expect(decimal_timer).not_to be_nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it "generates steps with proper indexing" do
|
|
55
|
+
steps = result[:sections].first[:content]
|
|
56
|
+
expect(steps.size).to be >= 10
|
|
57
|
+
|
|
58
|
+
# Check step numbers are sequential
|
|
59
|
+
step_numbers = steps.map { |s| s[:value][:number] }
|
|
60
|
+
expect(step_numbers).to eq((1..step_numbers.size).to_a)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
describe "fixed quantities" do
|
|
65
|
+
let(:recipe) { Cooklang.parse("Add @salt{=1%tsp} and @flour{500%g} and @pepper.") }
|
|
66
|
+
let(:result) { described_class.new(recipe).generate }
|
|
67
|
+
|
|
68
|
+
it "marks fixed ingredients as 'fixed' in data section" do
|
|
69
|
+
ingredient_data = result[:data][:ingredients]
|
|
70
|
+
|
|
71
|
+
# salt is fixed, flour is scaled, pepper has no quantity
|
|
72
|
+
expect(ingredient_data).to eq(%w[fixed scaled noQuantity])
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe Cooklang::Formatters::Json do
|
|
6
|
+
describe "#generate" do
|
|
7
|
+
let(:recipe) do
|
|
8
|
+
recipe_text = File.read(File.join(__dir__, "..", "fixtures", "comprehensive_recipe.cook"))
|
|
9
|
+
Cooklang.parse(recipe_text)
|
|
10
|
+
end
|
|
11
|
+
let(:json_output) { described_class.new(recipe).generate }
|
|
12
|
+
let(:parsed) { JSON.parse(json_output) }
|
|
13
|
+
|
|
14
|
+
it "generates valid JSON" do
|
|
15
|
+
expect { JSON.parse(json_output) }.not_to raise_error
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it "includes metadata with proper JSON types" do
|
|
19
|
+
expect(parsed["metadata"]["map"]["title"]).to eq("Uncle Roger's Ultimate Fried Rice & Dumplings")
|
|
20
|
+
expect(parsed["metadata"]["map"]["servings"]).to eq(4)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it "converts decimal quantities correctly" do
|
|
24
|
+
ingredients = parsed["ingredients"]
|
|
25
|
+
|
|
26
|
+
# MSG with decimal quantity
|
|
27
|
+
msg = ingredients.find { |i| i["name"] == "msg" }
|
|
28
|
+
expect(msg["quantity"]["value"]["value"]["value"]).to eq(0.5)
|
|
29
|
+
|
|
30
|
+
# White pepper with null quantity
|
|
31
|
+
white_pepper = ingredients.find { |i| i["name"] == "white pepper" }
|
|
32
|
+
expect(white_pepper["quantity"]).to be_nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it "handles named timers and decimal durations" do
|
|
36
|
+
timers = parsed["timers"]
|
|
37
|
+
|
|
38
|
+
# Named timer
|
|
39
|
+
named_timer = timers.find { |t| t["name"] == "quick scramble" }
|
|
40
|
+
expect(named_timer["quantity"]["value"]["value"]["value"]).to eq(45.0)
|
|
41
|
+
|
|
42
|
+
# Decimal duration
|
|
43
|
+
decimal_timer = timers.find { |t| t["quantity"]["value"]["value"]["value"] == 2.5 }
|
|
44
|
+
expect(decimal_timer).not_to be_nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it "formats with indentation when requested" do
|
|
48
|
+
formatted = described_class.new(recipe).generate(indent: " ")
|
|
49
|
+
expect(formatted).to include(" \"metadata\"")
|
|
50
|
+
# Just check that it's longer than compact format (indentation adds space)
|
|
51
|
+
compact = described_class.new(recipe).generate
|
|
52
|
+
expect(formatted.length).to be > compact.length
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -3,187 +3,49 @@
|
|
|
3
3
|
require "spec_helper"
|
|
4
4
|
|
|
5
5
|
RSpec.describe Cooklang::Formatters::Text do
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
cookware: [],
|
|
11
|
-
timers: [],
|
|
12
|
-
metadata: {},
|
|
13
|
-
sections: [],
|
|
14
|
-
notes: []
|
|
15
|
-
)
|
|
16
|
-
end
|
|
17
|
-
let(:formatter) { described_class.new(recipe) }
|
|
18
|
-
|
|
19
|
-
describe "#to_s" do
|
|
20
|
-
context "with ingredients only" do
|
|
21
|
-
let(:ingredients) do
|
|
22
|
-
[
|
|
23
|
-
Cooklang::Ingredient.new(name: "flour", quantity: 125, unit: "g"),
|
|
24
|
-
Cooklang::Ingredient.new(name: "milk", quantity: 250, unit: "ml"),
|
|
25
|
-
Cooklang::Ingredient.new(name: "eggs", quantity: 3),
|
|
26
|
-
Cooklang::Ingredient.new(name: "butter"),
|
|
27
|
-
Cooklang::Ingredient.new(name: "sea salt", quantity: 1, unit: "pinch")
|
|
28
|
-
]
|
|
29
|
-
end
|
|
30
|
-
let(:steps) { [] }
|
|
31
|
-
|
|
32
|
-
it "formats ingredients with aligned columns" do
|
|
33
|
-
expected = <<~OUTPUT.strip
|
|
34
|
-
Ingredients:
|
|
35
|
-
flour 125 g
|
|
36
|
-
milk 250 ml
|
|
37
|
-
eggs 3
|
|
38
|
-
butter some
|
|
39
|
-
sea salt 1 pinch
|
|
40
|
-
OUTPUT
|
|
41
|
-
|
|
42
|
-
expect(formatter.to_s).to eq(expected)
|
|
43
|
-
end
|
|
6
|
+
describe "#generate" do
|
|
7
|
+
let(:recipe) do
|
|
8
|
+
recipe_text = File.read(File.join(__dir__, "..", "fixtures", "comprehensive_recipe.cook"))
|
|
9
|
+
Cooklang.parse(recipe_text)
|
|
44
10
|
end
|
|
11
|
+
let(:output) { described_class.new(recipe).generate }
|
|
45
12
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
[
|
|
50
|
-
Cooklang::Step.new(segments: [
|
|
51
|
-
{ type: "text", value: "Crack the " },
|
|
52
|
-
{ type: "ingredient", value: "eggs", name: "eggs" },
|
|
53
|
-
{ type: "text", value: " into a blender" }
|
|
54
|
-
]),
|
|
55
|
-
Cooklang::Step.new(segments: [
|
|
56
|
-
{ type: "text", value: "Pour into a bowl and leave to stand for " },
|
|
57
|
-
{ type: "timer", value: "15 minutes", name: nil },
|
|
58
|
-
{ type: "text", value: "." }
|
|
59
|
-
])
|
|
60
|
-
]
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
it "formats steps with numbered list" do
|
|
64
|
-
expected = <<~OUTPUT.strip
|
|
65
|
-
Steps:
|
|
66
|
-
1. Crack the eggs into a blender
|
|
67
|
-
2. Pour into a bowl and leave to stand for 15 minutes.
|
|
68
|
-
OUTPUT
|
|
69
|
-
|
|
70
|
-
expect(formatter.to_s).to eq(expected)
|
|
71
|
-
end
|
|
13
|
+
it "includes Ingredients and Steps sections" do
|
|
14
|
+
expect(output).to include("Ingredients:")
|
|
15
|
+
expect(output).to include("Steps:")
|
|
72
16
|
end
|
|
73
17
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
Cooklang::Ingredient.new(name: "flour", quantity: 125, unit: "g"),
|
|
80
|
-
Cooklang::Ingredient.new(name: "milk", quantity: 250, unit: "ml"),
|
|
81
|
-
Cooklang::Ingredient.new(name: "sea salt", quantity: 1, unit: "pinch")
|
|
82
|
-
]
|
|
83
|
-
end
|
|
84
|
-
let(:steps) do
|
|
85
|
-
[
|
|
86
|
-
Cooklang::Step.new(segments: [
|
|
87
|
-
{ type: "text", value: "Crack the " },
|
|
88
|
-
{ type: "ingredient", value: "eggs", name: "eggs" },
|
|
89
|
-
{ type: "text", value: " into a blender, then add the " },
|
|
90
|
-
{ type: "ingredient", value: "flour", name: "flour" },
|
|
91
|
-
{ type: "text", value: ", " },
|
|
92
|
-
{ type: "ingredient", value: "milk", name: "milk" },
|
|
93
|
-
{ type: "text", value: " and " },
|
|
94
|
-
{ type: "ingredient", value: "sea salt", name: "sea salt" },
|
|
95
|
-
{ type: "text", value: "." }
|
|
96
|
-
]),
|
|
97
|
-
Cooklang::Step.new(segments: [
|
|
98
|
-
{ type: "text", value: "Pour into a bowl and leave to stand for " },
|
|
99
|
-
{ type: "timer", value: "15 minutes", name: nil },
|
|
100
|
-
{ type: "text", value: "." }
|
|
101
|
-
]),
|
|
102
|
-
Cooklang::Step.new(segments: [
|
|
103
|
-
{ type: "text", value: "Melt the " },
|
|
104
|
-
{ type: "ingredient", value: "butter", name: "butter" },
|
|
105
|
-
{ type: "text", value: " in a large non-stick " },
|
|
106
|
-
{ type: "cookware", value: "frying pan", name: "frying pan" },
|
|
107
|
-
{ type: "text", value: "." }
|
|
108
|
-
])
|
|
109
|
-
]
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
it "formats complete recipe" do
|
|
113
|
-
expected = <<~OUTPUT.strip
|
|
114
|
-
Ingredients:
|
|
115
|
-
butter some
|
|
116
|
-
eggs 3
|
|
117
|
-
flour 125 g
|
|
118
|
-
milk 250 ml
|
|
119
|
-
sea salt 1 pinch
|
|
120
|
-
|
|
121
|
-
Steps:
|
|
122
|
-
1. Crack the eggs into a blender, then add the flour, milk and sea salt.
|
|
123
|
-
2. Pour into a bowl and leave to stand for 15 minutes.
|
|
124
|
-
3. Melt the butter in a large non-stick frying pan.
|
|
125
|
-
OUTPUT
|
|
126
|
-
|
|
127
|
-
expect(formatter.to_s).to eq(expected)
|
|
128
|
-
end
|
|
18
|
+
it "formats ingredients with quantities and units" do
|
|
19
|
+
expect(output).to match(/shallots\s+2/)
|
|
20
|
+
expect(output).to match(/garlic\s+4 cloves/)
|
|
21
|
+
expect(output).to match(/msg\s+0\.5 tsp/)
|
|
22
|
+
expect(output).to match(/white pepper\s+some/)
|
|
129
23
|
end
|
|
130
24
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
it "returns empty string" do
|
|
136
|
-
expect(formatter.to_s).to eq("")
|
|
137
|
-
end
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
context "with various ingredient formats" do
|
|
141
|
-
let(:ingredients) do
|
|
142
|
-
[
|
|
143
|
-
Cooklang::Ingredient.new(name: "onion", quantity: 1),
|
|
144
|
-
Cooklang::Ingredient.new(name: "olive oil", unit: "drizzle"),
|
|
145
|
-
Cooklang::Ingredient.new(name: "salt"),
|
|
146
|
-
Cooklang::Ingredient.new(name: "pepper", quantity: "some", unit: "grinds")
|
|
147
|
-
]
|
|
148
|
-
end
|
|
149
|
-
let(:steps) { [] }
|
|
150
|
-
|
|
151
|
-
it "handles missing quantities and units gracefully" do
|
|
152
|
-
expected = <<~OUTPUT.strip
|
|
153
|
-
Ingredients:
|
|
154
|
-
onion 1
|
|
155
|
-
olive oil drizzle
|
|
156
|
-
salt some
|
|
157
|
-
pepper some grinds
|
|
158
|
-
OUTPUT
|
|
159
|
-
|
|
160
|
-
expect(formatter.to_s).to eq(expected)
|
|
161
|
-
end
|
|
162
|
-
end
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
describe "#format_quantity_unit" do
|
|
166
|
-
let(:formatter) { described_class.new(recipe) }
|
|
167
|
-
let(:recipe) { double("recipe") }
|
|
25
|
+
it "formats steps with sequential numbering" do
|
|
26
|
+
lines = output.split("\n")
|
|
27
|
+
step_lines = lines.select { |line| line.match(/^\s*\d+\./) }
|
|
168
28
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
expect(
|
|
29
|
+
expect(step_lines.size).to be >= 10
|
|
30
|
+
expect(step_lines.first).to match(/1\..*/)
|
|
31
|
+
expect(step_lines.last).to match(/\d+\..*/)
|
|
172
32
|
end
|
|
173
33
|
|
|
174
|
-
it "
|
|
175
|
-
|
|
176
|
-
expect(
|
|
34
|
+
it "includes ingredient names in step text" do
|
|
35
|
+
expect(output).to include("shallots")
|
|
36
|
+
expect(output).to include("garlic")
|
|
37
|
+
expect(output).to include("day-old rice")
|
|
177
38
|
end
|
|
178
39
|
|
|
179
|
-
it "
|
|
180
|
-
|
|
181
|
-
expect(
|
|
40
|
+
it "includes timer references in steps" do
|
|
41
|
+
expect(output).to include("2 minutes")
|
|
42
|
+
expect(output).to include("30 seconds")
|
|
43
|
+
expect(output).to include("quick scramble")
|
|
182
44
|
end
|
|
183
45
|
|
|
184
|
-
it "
|
|
185
|
-
|
|
186
|
-
expect(
|
|
46
|
+
it "includes cookware references" do
|
|
47
|
+
expect(output).to include("wok")
|
|
48
|
+
expect(output).to include("mixing bowl")
|
|
187
49
|
end
|
|
188
50
|
end
|
|
189
51
|
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe "Canonical Metadata Support" do
|
|
6
|
+
let(:parser) { Cooklang::Parser.new }
|
|
7
|
+
|
|
8
|
+
describe "Complex YAML frontmatter" do
|
|
9
|
+
it "supports arrays in metadata" do
|
|
10
|
+
recipe_text = <<~COOK
|
|
11
|
+
---
|
|
12
|
+
title: Pasta Dish
|
|
13
|
+
tags: [italian, pasta, quick]
|
|
14
|
+
diet:
|
|
15
|
+
- vegetarian
|
|
16
|
+
- gluten-free
|
|
17
|
+
---
|
|
18
|
+
@pasta{200%g}
|
|
19
|
+
COOK
|
|
20
|
+
|
|
21
|
+
recipe = parser.parse(recipe_text)
|
|
22
|
+
|
|
23
|
+
expect(recipe.metadata["tags"]).to eq(["italian", "pasta", "quick"])
|
|
24
|
+
expect(recipe.metadata["diet"]).to eq(["vegetarian", "gluten-free"])
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it "supports nested hash structures" do
|
|
28
|
+
recipe_text = <<~COOK
|
|
29
|
+
---
|
|
30
|
+
title: Recipe
|
|
31
|
+
source:
|
|
32
|
+
name: My Cookbook
|
|
33
|
+
url: https://example.com/recipe
|
|
34
|
+
author: Jane Doe
|
|
35
|
+
time:
|
|
36
|
+
prep: 15 minutes
|
|
37
|
+
cook: 30 minutes
|
|
38
|
+
---
|
|
39
|
+
@flour{100%g}
|
|
40
|
+
COOK
|
|
41
|
+
|
|
42
|
+
recipe = parser.parse(recipe_text)
|
|
43
|
+
|
|
44
|
+
expect(recipe.metadata["source"]).to be_a(Hash)
|
|
45
|
+
expect(recipe.metadata["source"]["name"]).to eq("My Cookbook")
|
|
46
|
+
expect(recipe.metadata["source"]["url"]).to eq("https://example.com/recipe")
|
|
47
|
+
expect(recipe.metadata["source"]["author"]).to eq("Jane Doe")
|
|
48
|
+
|
|
49
|
+
expect(recipe.metadata["time"]).to be_a(Hash)
|
|
50
|
+
expect(recipe.metadata["time"]["prep"]).to eq("15 minutes")
|
|
51
|
+
expect(recipe.metadata["time"]["cook"]).to eq("30 minutes")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it "supports mixed data types" do
|
|
55
|
+
recipe_text = <<~COOK
|
|
56
|
+
---
|
|
57
|
+
title: Test Recipe
|
|
58
|
+
servings: 4
|
|
59
|
+
rating: 4.5
|
|
60
|
+
published: true
|
|
61
|
+
custom_field: Some value
|
|
62
|
+
numbers: [1, 2, 3]
|
|
63
|
+
---
|
|
64
|
+
@salt
|
|
65
|
+
COOK
|
|
66
|
+
|
|
67
|
+
recipe = parser.parse(recipe_text)
|
|
68
|
+
|
|
69
|
+
expect(recipe.metadata["title"]).to eq("Test Recipe")
|
|
70
|
+
expect(recipe.metadata["servings"]).to eq(4)
|
|
71
|
+
expect(recipe.metadata["rating"]).to eq(4.5)
|
|
72
|
+
expect(recipe.metadata["published"]).to eq(true)
|
|
73
|
+
expect(recipe.metadata["custom_field"]).to eq("Some value")
|
|
74
|
+
expect(recipe.metadata["numbers"]).to eq([1, 2, 3])
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it "raises error on invalid YAML" do
|
|
78
|
+
recipe_text = <<~COOK
|
|
79
|
+
---
|
|
80
|
+
title: Test
|
|
81
|
+
tags: [invalid
|
|
82
|
+
not closed
|
|
83
|
+
---
|
|
84
|
+
@salt
|
|
85
|
+
COOK
|
|
86
|
+
|
|
87
|
+
expect { parser.parse(recipe_text) }.to raise_error(Psych::SyntaxError)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
describe "Canonical fields work as plain Hash values" do
|
|
92
|
+
let(:metadata) { Cooklang::Metadata.new }
|
|
93
|
+
|
|
94
|
+
it "stores servings as number or string" do
|
|
95
|
+
metadata["servings"] = 4
|
|
96
|
+
expect(metadata["servings"]).to eq(4)
|
|
97
|
+
|
|
98
|
+
metadata["servings"] = "6 cups worth"
|
|
99
|
+
expect(metadata["servings"]).to eq("6 cups worth")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it "stores time fields as nested or flat" do
|
|
103
|
+
metadata["time"] = { "prep" => "10 min", "cook" => "20 min" }
|
|
104
|
+
expect(metadata["time"]["prep"]).to eq("10 min")
|
|
105
|
+
expect(metadata["time"]["cook"]).to eq("20 min")
|
|
106
|
+
|
|
107
|
+
metadata.clear
|
|
108
|
+
metadata["prep_time"] = "15 minutes"
|
|
109
|
+
metadata["cook_time"] = "25 minutes"
|
|
110
|
+
expect(metadata["prep_time"]).to eq("15 minutes")
|
|
111
|
+
expect(metadata["cook_time"]).to eq("25 minutes")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it "stores source as string or nested hash" do
|
|
115
|
+
metadata["source"] = {
|
|
116
|
+
"name" => "The Cookbook",
|
|
117
|
+
"url" => "https://example.com",
|
|
118
|
+
"author" => "Chef Name"
|
|
119
|
+
}
|
|
120
|
+
expect(metadata["source"]["name"]).to eq("The Cookbook")
|
|
121
|
+
expect(metadata["source"]["url"]).to eq("https://example.com")
|
|
122
|
+
expect(metadata["source"]["author"]).to eq("Chef Name")
|
|
123
|
+
|
|
124
|
+
metadata.clear
|
|
125
|
+
metadata["source"] = "Grandma's recipe box"
|
|
126
|
+
expect(metadata["source"]).to eq("Grandma's recipe box")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
it "stores tags and diet as arrays" do
|
|
130
|
+
metadata["tags"] = ["pasta", "italian"]
|
|
131
|
+
expect(metadata["tags"]).to eq(["pasta", "italian"])
|
|
132
|
+
|
|
133
|
+
metadata["diet"] = ["vegan", "gluten-free"]
|
|
134
|
+
expect(metadata["diet"]).to eq(["vegan", "gluten-free"])
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
it "stores other canonical fields" do
|
|
138
|
+
metadata["category"] = "dessert"
|
|
139
|
+
metadata["description"] = "A delicious recipe"
|
|
140
|
+
metadata["difficulty"] = "easy"
|
|
141
|
+
metadata["cuisine"] = "Italian"
|
|
142
|
+
metadata["locale"] = "en_US"
|
|
143
|
+
metadata["images"] = ["url1", "url2"]
|
|
144
|
+
|
|
145
|
+
expect(metadata["category"]).to eq("dessert")
|
|
146
|
+
expect(metadata["description"]).to eq("A delicious recipe")
|
|
147
|
+
expect(metadata["difficulty"]).to eq("easy")
|
|
148
|
+
expect(metadata["cuisine"]).to eq("Italian")
|
|
149
|
+
expect(metadata["locale"]).to eq("en_US")
|
|
150
|
+
expect(metadata["images"]).to eq(["url1", "url2"])
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
describe "Custom metadata fields" do
|
|
155
|
+
it "allows any custom fields" do
|
|
156
|
+
metadata = Cooklang::Metadata.new(
|
|
157
|
+
"wine_pairing" => "Pinot Noir",
|
|
158
|
+
"my_rating" => 5,
|
|
159
|
+
"special_equipment" => ["thermometer", "stand mixer"],
|
|
160
|
+
"notes_to_self" => "Double the garlic next time"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
expect(metadata["wine_pairing"]).to eq("Pinot Noir")
|
|
164
|
+
expect(metadata["my_rating"]).to eq(5)
|
|
165
|
+
expect(metadata["special_equipment"]).to eq(["thermometer", "stand mixer"])
|
|
166
|
+
expect(metadata["notes_to_self"]).to eq("Double the garlic next time")
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
data/spec/lexer_spec.rb
CHANGED
|
@@ -146,12 +146,12 @@ RSpec.describe Cooklang::Lexer do
|
|
|
146
146
|
end
|
|
147
147
|
end
|
|
148
148
|
|
|
149
|
-
context "with section markers" do
|
|
149
|
+
context "with equals (section markers)" do
|
|
150
150
|
it "tokenizes single equals" do
|
|
151
151
|
lexer = described_class.new("= Dough")
|
|
152
152
|
tokens = lexer.tokenize
|
|
153
153
|
|
|
154
|
-
expect(tokens.map(&:type)).to eq(%i[
|
|
154
|
+
expect(tokens.map(&:type)).to eq(%i[equals text])
|
|
155
155
|
expect(tokens.map(&:value)).to eq(["=", " Dough"])
|
|
156
156
|
end
|
|
157
157
|
|
|
@@ -159,7 +159,7 @@ RSpec.describe Cooklang::Lexer do
|
|
|
159
159
|
lexer = described_class.new("== Filling ==")
|
|
160
160
|
tokens = lexer.tokenize
|
|
161
161
|
|
|
162
|
-
expect(tokens.map(&:type)).to eq(%i[
|
|
162
|
+
expect(tokens.map(&:type)).to eq(%i[equals text equals])
|
|
163
163
|
expect(tokens.map(&:value)).to eq(["==", " Filling ", "=="])
|
|
164
164
|
end
|
|
165
165
|
|
|
@@ -167,7 +167,7 @@ RSpec.describe Cooklang::Lexer do
|
|
|
167
167
|
lexer = described_class.new("=== Section ===")
|
|
168
168
|
tokens = lexer.tokenize
|
|
169
169
|
|
|
170
|
-
expect(tokens.map(&:type)).to eq(%i[
|
|
170
|
+
expect(tokens.map(&:type)).to eq(%i[equals text equals])
|
|
171
171
|
expect(tokens.map(&:value)).to eq(["===", " Section ", "==="])
|
|
172
172
|
end
|
|
173
173
|
end
|
|
@@ -47,6 +47,20 @@ RSpec.describe Cooklang::Ingredient do
|
|
|
47
47
|
expect(ingredient.notes).to eq("sifted")
|
|
48
48
|
expect(ingredient.notes).to be_frozen
|
|
49
49
|
end
|
|
50
|
+
|
|
51
|
+
it "defaults fixed to false" do
|
|
52
|
+
ingredient = described_class.new(name: "salt")
|
|
53
|
+
|
|
54
|
+
expect(ingredient.fixed).to be false
|
|
55
|
+
expect(ingredient.fixed?).to be false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it "accepts fixed parameter" do
|
|
59
|
+
ingredient = described_class.new(name: "salt", quantity: 1, unit: "tsp", fixed: true)
|
|
60
|
+
|
|
61
|
+
expect(ingredient.fixed).to be true
|
|
62
|
+
expect(ingredient.fixed?).to be true
|
|
63
|
+
end
|
|
50
64
|
end
|
|
51
65
|
|
|
52
66
|
describe "#to_s" do
|
|
@@ -94,16 +108,34 @@ RSpec.describe Cooklang::Ingredient do
|
|
|
94
108
|
name: "flour",
|
|
95
109
|
quantity: 125,
|
|
96
110
|
unit: "g",
|
|
97
|
-
notes: "sifted"
|
|
111
|
+
notes: "sifted",
|
|
112
|
+
fixed: false
|
|
98
113
|
}
|
|
99
114
|
|
|
100
115
|
expect(ingredient.to_h).to eq(expected)
|
|
101
116
|
end
|
|
102
117
|
|
|
103
|
-
it "omits nil attributes" do
|
|
118
|
+
it "omits nil attributes but includes fixed" do
|
|
104
119
|
ingredient = described_class.new(name: "salt")
|
|
105
120
|
|
|
106
|
-
expect(ingredient.to_h).to eq({ name: "salt" })
|
|
121
|
+
expect(ingredient.to_h).to eq({ name: "salt", fixed: false })
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it "includes fixed when true" do
|
|
125
|
+
ingredient = described_class.new(name: "salt", quantity: 1, unit: "tsp", fixed: true)
|
|
126
|
+
|
|
127
|
+
expect(ingredient.to_h).to eq({
|
|
128
|
+
name: "salt",
|
|
129
|
+
quantity: 1,
|
|
130
|
+
unit: "tsp",
|
|
131
|
+
fixed: true
|
|
132
|
+
})
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
it "includes fixed as false when not fixed" do
|
|
136
|
+
ingredient = described_class.new(name: "salt", quantity: 1, unit: "tsp", fixed: false)
|
|
137
|
+
|
|
138
|
+
expect(ingredient.to_h[:fixed]).to be false
|
|
107
139
|
end
|
|
108
140
|
end
|
|
109
141
|
|
|
@@ -129,6 +161,13 @@ RSpec.describe Cooklang::Ingredient do
|
|
|
129
161
|
expect(ingredient1).not_to eq(ingredient2)
|
|
130
162
|
end
|
|
131
163
|
|
|
164
|
+
it "returns false for ingredients with different fixed values" do
|
|
165
|
+
ingredient1 = described_class.new(name: "salt", quantity: 1, fixed: true)
|
|
166
|
+
ingredient2 = described_class.new(name: "salt", quantity: 1, fixed: false)
|
|
167
|
+
|
|
168
|
+
expect(ingredient1).not_to eq(ingredient2)
|
|
169
|
+
end
|
|
170
|
+
|
|
132
171
|
it "returns false for non-Ingredient objects" do
|
|
133
172
|
ingredient = described_class.new(name: "flour")
|
|
134
173
|
|
|
@@ -172,6 +211,23 @@ RSpec.describe Cooklang::Ingredient do
|
|
|
172
211
|
expect(ingredient).not_to have_notes
|
|
173
212
|
end
|
|
174
213
|
end
|
|
214
|
+
|
|
215
|
+
describe "#fixed?" do
|
|
216
|
+
it "returns true when fixed is true" do
|
|
217
|
+
ingredient = described_class.new(name: "salt", quantity: 1, fixed: true)
|
|
218
|
+
expect(ingredient).to be_fixed
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
it "returns false when fixed is false" do
|
|
222
|
+
ingredient = described_class.new(name: "salt", quantity: 1, fixed: false)
|
|
223
|
+
expect(ingredient).not_to be_fixed
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
it "returns false by default" do
|
|
227
|
+
ingredient = described_class.new(name: "salt")
|
|
228
|
+
expect(ingredient).not_to be_fixed
|
|
229
|
+
end
|
|
230
|
+
end
|
|
175
231
|
end
|
|
176
232
|
|
|
177
233
|
describe "#hash" do
|