cooklang 1.0.0 → 1.0.2
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/.github/workflows/test.yml +35 -0
- data/.gitignore +12 -0
- data/.qlty/.gitignore +7 -0
- data/.qlty/configs/.yamllint.yaml +21 -0
- data/.qlty/qlty.toml +101 -0
- data/.rspec +3 -0
- data/.rubocop.yml +340 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +84 -0
- data/README.md +197 -17
- data/Rakefile +12 -0
- data/cooklang.gemspec +35 -0
- data/lib/cooklang/builders/recipe_builder.rb +60 -0
- data/lib/cooklang/builders/step_builder.rb +74 -0
- data/lib/cooklang/formatter.rb +6 -2
- data/lib/cooklang/formatters/hash.rb +257 -0
- data/lib/cooklang/formatters/json.rb +13 -0
- data/lib/cooklang/formatters/text.rb +1 -3
- data/lib/cooklang/lexer.rb +5 -14
- data/lib/cooklang/parser.rb +18 -653
- data/lib/cooklang/parsers/cookware_parser.rb +130 -0
- data/lib/cooklang/parsers/ingredient_parser.rb +176 -0
- data/lib/cooklang/parsers/timer_parser.rb +132 -0
- data/lib/cooklang/processors/element_parser.rb +40 -0
- data/lib/cooklang/processors/metadata_processor.rb +127 -0
- data/lib/cooklang/processors/step_processor.rb +204 -0
- data/lib/cooklang/processors/token_processor.rb +101 -0
- data/lib/cooklang/recipe.rb +31 -24
- data/lib/cooklang/step.rb +12 -2
- data/lib/cooklang/timer.rb +3 -1
- data/lib/cooklang/token_stream.rb +130 -0
- data/lib/cooklang/version.rb +1 -1
- data/lib/cooklang.rb +31 -3
- data/spec/comprehensive_spec.rb +179 -0
- data/spec/cooklang_spec.rb +38 -0
- data/spec/fixtures/canonical.yaml +837 -0
- data/spec/formatters/text_spec.rb +189 -0
- data/spec/integration/canonical_spec.rb +211 -0
- data/spec/lexer_spec.rb +357 -0
- data/spec/models/cookware_spec.rb +116 -0
- data/spec/models/ingredient_spec.rb +192 -0
- data/spec/models/metadata_spec.rb +241 -0
- data/spec/models/note_spec.rb +65 -0
- data/spec/models/recipe_spec.rb +374 -0
- data/spec/models/section_spec.rb +65 -0
- data/spec/models/step_spec.rb +236 -0
- data/spec/models/timer_spec.rb +173 -0
- data/spec/parser_spec.rb +398 -0
- data/spec/spec_helper.rb +23 -0
- data/spec/token_stream_spec.rb +278 -0
- metadata +159 -4
@@ -0,0 +1,241 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "spec_helper"
|
4
|
+
|
5
|
+
RSpec.describe Cooklang::Metadata do
|
6
|
+
describe "#initialize" do
|
7
|
+
it "creates empty metadata" do
|
8
|
+
metadata = described_class.new
|
9
|
+
|
10
|
+
expect(metadata).to be_empty
|
11
|
+
end
|
12
|
+
|
13
|
+
it "creates metadata from hash" do
|
14
|
+
data = { "title" => "Test Recipe", "servings" => 4 }
|
15
|
+
metadata = described_class.new(data)
|
16
|
+
|
17
|
+
expect(metadata["title"]).to eq("Test Recipe")
|
18
|
+
expect(metadata["servings"]).to eq(4)
|
19
|
+
end
|
20
|
+
|
21
|
+
it "converts symbol keys to strings" do
|
22
|
+
data = { title: "Test Recipe", servings: 4 }
|
23
|
+
metadata = described_class.new(data)
|
24
|
+
|
25
|
+
expect(metadata["title"]).to eq("Test Recipe")
|
26
|
+
expect(metadata["servings"]).to eq(4)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
describe "hash access methods" do
|
31
|
+
let(:metadata) { described_class.new(title: "Test Recipe", servings: 4) }
|
32
|
+
|
33
|
+
describe "#[]=" do
|
34
|
+
it "converts keys to strings" do
|
35
|
+
metadata[:prep_time] = "10 minutes"
|
36
|
+
|
37
|
+
expect(metadata["prep_time"]).to eq("10 minutes")
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
describe "#[]" do
|
42
|
+
it "converts keys to strings" do
|
43
|
+
expect(metadata[:title]).to eq("Test Recipe")
|
44
|
+
expect(metadata["title"]).to eq("Test Recipe")
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
describe "#key?" do
|
49
|
+
it "converts keys to strings" do
|
50
|
+
expect(metadata.key?(:title)).to be true
|
51
|
+
expect(metadata.key?("title")).to be true
|
52
|
+
expect(metadata.key?(:nonexistent)).to be false
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
describe "#delete" do
|
57
|
+
it "converts keys to strings" do
|
58
|
+
metadata.delete(:title)
|
59
|
+
|
60
|
+
expect(metadata).not_to have_key("title")
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
describe "#fetch" do
|
65
|
+
it "converts keys to strings" do
|
66
|
+
expect(metadata.fetch(:title)).to eq("Test Recipe")
|
67
|
+
expect(metadata.fetch(:nonexistent, "default")).to eq("default")
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
describe "#to_h" do
|
73
|
+
it "returns plain hash" do
|
74
|
+
metadata = described_class.new(title: "Test Recipe", servings: 4)
|
75
|
+
|
76
|
+
hash = metadata.to_h
|
77
|
+
|
78
|
+
expect(hash).to be_a(Hash)
|
79
|
+
expect(hash).not_to be_a(described_class)
|
80
|
+
expect(hash).to eq({ "title" => "Test Recipe", "servings" => 4 })
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
describe "recipe-specific accessors" do
|
85
|
+
let(:metadata) { described_class.new }
|
86
|
+
|
87
|
+
describe "#servings" do
|
88
|
+
it "returns integer servings" do
|
89
|
+
metadata["servings"] = "4"
|
90
|
+
|
91
|
+
expect(metadata.servings).to eq(4)
|
92
|
+
end
|
93
|
+
|
94
|
+
it "returns nil when not set" do
|
95
|
+
expect(metadata.servings).to be_nil
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
describe "#servings=" do
|
100
|
+
it "sets servings" do
|
101
|
+
metadata.servings = 6
|
102
|
+
|
103
|
+
expect(metadata["servings"]).to eq(6)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
describe "#prep_time" do
|
108
|
+
it "returns prep_time" do
|
109
|
+
metadata["prep_time"] = "15 minutes"
|
110
|
+
|
111
|
+
expect(metadata.prep_time).to eq("15 minutes")
|
112
|
+
end
|
113
|
+
|
114
|
+
it "returns prep-time as fallback" do
|
115
|
+
metadata["prep-time"] = "15 minutes"
|
116
|
+
|
117
|
+
expect(metadata.prep_time).to eq("15 minutes")
|
118
|
+
end
|
119
|
+
|
120
|
+
it "returns nil when not set" do
|
121
|
+
expect(metadata.prep_time).to be_nil
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
describe "#prep_time=" do
|
126
|
+
it "sets prep_time" do
|
127
|
+
metadata.prep_time = "20 minutes"
|
128
|
+
|
129
|
+
expect(metadata["prep_time"]).to eq("20 minutes")
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
describe "#cook_time" do
|
134
|
+
it "returns cook_time" do
|
135
|
+
metadata["cook_time"] = "30 minutes"
|
136
|
+
|
137
|
+
expect(metadata.cook_time).to eq("30 minutes")
|
138
|
+
end
|
139
|
+
|
140
|
+
it "returns cook-time as fallback" do
|
141
|
+
metadata["cook-time"] = "30 minutes"
|
142
|
+
|
143
|
+
expect(metadata.cook_time).to eq("30 minutes")
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
describe "#cook_time=" do
|
148
|
+
it "sets cook_time" do
|
149
|
+
metadata.cook_time = "25 minutes"
|
150
|
+
|
151
|
+
expect(metadata["cook_time"]).to eq("25 minutes")
|
152
|
+
end
|
153
|
+
end
|
154
|
+
|
155
|
+
describe "#total_time" do
|
156
|
+
it "returns total_time" do
|
157
|
+
metadata["total_time"] = "45 minutes"
|
158
|
+
|
159
|
+
expect(metadata.total_time).to eq("45 minutes")
|
160
|
+
end
|
161
|
+
|
162
|
+
it "returns total-time as fallback" do
|
163
|
+
metadata["total-time"] = "45 minutes"
|
164
|
+
|
165
|
+
expect(metadata.total_time).to eq("45 minutes")
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
describe "#total_time=" do
|
170
|
+
it "sets total_time" do
|
171
|
+
metadata.total_time = "50 minutes"
|
172
|
+
|
173
|
+
expect(metadata["total_time"]).to eq("50 minutes")
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
describe "#title" do
|
178
|
+
it "returns title" do
|
179
|
+
metadata["title"] = "Chocolate Cake"
|
180
|
+
|
181
|
+
expect(metadata.title).to eq("Chocolate Cake")
|
182
|
+
end
|
183
|
+
end
|
184
|
+
|
185
|
+
describe "#title=" do
|
186
|
+
it "sets title" do
|
187
|
+
metadata.title = "Vanilla Cake"
|
188
|
+
|
189
|
+
expect(metadata["title"]).to eq("Vanilla Cake")
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
describe "#source" do
|
194
|
+
it "returns source" do
|
195
|
+
metadata["source"] = "cookbook.com"
|
196
|
+
|
197
|
+
expect(metadata.source).to eq("cookbook.com")
|
198
|
+
end
|
199
|
+
end
|
200
|
+
|
201
|
+
describe "#source=" do
|
202
|
+
it "sets source" do
|
203
|
+
metadata.source = "my-blog.com"
|
204
|
+
|
205
|
+
expect(metadata["source"]).to eq("my-blog.com")
|
206
|
+
end
|
207
|
+
end
|
208
|
+
|
209
|
+
describe "#tags" do
|
210
|
+
it "returns array when tags is array" do
|
211
|
+
metadata["tags"] = ["dessert", "chocolate"]
|
212
|
+
|
213
|
+
expect(metadata.tags).to eq(["dessert", "chocolate"])
|
214
|
+
end
|
215
|
+
|
216
|
+
it "splits string tags on comma" do
|
217
|
+
metadata["tags"] = "dessert, chocolate, sweet"
|
218
|
+
|
219
|
+
expect(metadata.tags).to eq(["dessert", "chocolate", "sweet"])
|
220
|
+
end
|
221
|
+
|
222
|
+
it "returns empty array when not set" do
|
223
|
+
expect(metadata.tags).to eq([])
|
224
|
+
end
|
225
|
+
|
226
|
+
it "returns empty array for non-string, non-array values" do
|
227
|
+
metadata["tags"] = 123
|
228
|
+
|
229
|
+
expect(metadata.tags).to eq([])
|
230
|
+
end
|
231
|
+
end
|
232
|
+
|
233
|
+
describe "#tags=" do
|
234
|
+
it "sets tags" do
|
235
|
+
metadata.tags = ["breakfast", "quick"]
|
236
|
+
|
237
|
+
expect(metadata["tags"]).to eq(["breakfast", "quick"])
|
238
|
+
end
|
239
|
+
end
|
240
|
+
end
|
241
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "spec_helper"
|
4
|
+
|
5
|
+
RSpec.describe Cooklang::Note do
|
6
|
+
describe "#initialize" do
|
7
|
+
it "creates note with content" do
|
8
|
+
note = described_class.new(content: "This is a note")
|
9
|
+
expect(note.content).to eq("This is a note")
|
10
|
+
end
|
11
|
+
|
12
|
+
it "converts content to string and freezes it" do
|
13
|
+
note = described_class.new(content: 123)
|
14
|
+
expect(note.content).to eq("123")
|
15
|
+
expect(note.content).to be_frozen
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
describe "#to_s" do
|
20
|
+
it "returns content" do
|
21
|
+
note = described_class.new(content: "Test note")
|
22
|
+
expect(note.to_s).to eq("Test note")
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
describe "#to_h" do
|
27
|
+
it "returns hash representation" do
|
28
|
+
note = described_class.new(content: "Test note")
|
29
|
+
expect(note.to_h).to eq({ content: "Test note" })
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
describe "#==" do
|
34
|
+
it "returns true for notes with same content" do
|
35
|
+
note1 = described_class.new(content: "Same content")
|
36
|
+
note2 = described_class.new(content: "Same content")
|
37
|
+
expect(note1 == note2).to be_truthy
|
38
|
+
end
|
39
|
+
|
40
|
+
it "returns false for notes with different content" do
|
41
|
+
note1 = described_class.new(content: "Content 1")
|
42
|
+
note2 = described_class.new(content: "Content 2")
|
43
|
+
expect(note1 == note2).to be_falsey
|
44
|
+
end
|
45
|
+
|
46
|
+
it "returns false for non-Note objects" do
|
47
|
+
note = described_class.new(content: "Test")
|
48
|
+
expect(note == "Test").to be_falsey
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
describe "#hash" do
|
53
|
+
it "generates same hash for equal notes" do
|
54
|
+
note1 = described_class.new(content: "Test")
|
55
|
+
note2 = described_class.new(content: "Test")
|
56
|
+
expect(note1.hash).to eq(note2.hash)
|
57
|
+
end
|
58
|
+
|
59
|
+
it "generates different hash for different notes" do
|
60
|
+
note1 = described_class.new(content: "Test 1")
|
61
|
+
note2 = described_class.new(content: "Test 2")
|
62
|
+
expect(note1.hash).not_to eq(note2.hash)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -0,0 +1,374 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "spec_helper"
|
4
|
+
|
5
|
+
RSpec.describe Cooklang::Recipe do
|
6
|
+
let(:ingredient) { Cooklang::Ingredient.new(name: "flour", quantity: 125, unit: "g") }
|
7
|
+
let(:cookware) { Cooklang::Cookware.new(name: "pan") }
|
8
|
+
let(:timer) { Cooklang::Timer.new(duration: 5, unit: "minutes") }
|
9
|
+
let(:step) { Cooklang::Step.new(segments: ["Mix the ", { type: :ingredient, name: "flour" }]) }
|
10
|
+
let(:metadata) { Cooklang::Metadata.new(title: "Test Recipe") }
|
11
|
+
|
12
|
+
describe "#initialize" do
|
13
|
+
it "creates a recipe with all components" do
|
14
|
+
recipe = described_class.new(
|
15
|
+
ingredients: [ingredient],
|
16
|
+
cookware: [cookware],
|
17
|
+
timers: [timer],
|
18
|
+
steps: [step],
|
19
|
+
metadata: metadata
|
20
|
+
)
|
21
|
+
|
22
|
+
expect(recipe.ingredients).to eq([ingredient])
|
23
|
+
expect(recipe.cookware).to eq([cookware])
|
24
|
+
expect(recipe.timers).to eq([timer])
|
25
|
+
expect(recipe.steps).to eq([step])
|
26
|
+
expect(recipe.metadata).to eq(metadata)
|
27
|
+
end
|
28
|
+
|
29
|
+
it "freezes arrays to prevent modification" do
|
30
|
+
recipe = described_class.new(
|
31
|
+
ingredients: [ingredient],
|
32
|
+
cookware: [cookware],
|
33
|
+
timers: [timer],
|
34
|
+
steps: [step],
|
35
|
+
metadata: metadata
|
36
|
+
)
|
37
|
+
|
38
|
+
expect(recipe.ingredients).to be_frozen
|
39
|
+
expect(recipe.cookware).to be_frozen
|
40
|
+
expect(recipe.timers).to be_frozen
|
41
|
+
expect(recipe.steps).to be_frozen
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
describe "#ingredients_hash" do
|
46
|
+
it "returns ingredients as a hash with quantity and unit" do
|
47
|
+
ingredient1 = Cooklang::Ingredient.new(name: "flour", quantity: 125, unit: "g")
|
48
|
+
ingredient2 = Cooklang::Ingredient.new(name: "salt", quantity: 1, unit: "pinch")
|
49
|
+
|
50
|
+
recipe = described_class.new(
|
51
|
+
ingredients: [ingredient1, ingredient2],
|
52
|
+
cookware: [],
|
53
|
+
timers: [],
|
54
|
+
steps: [],
|
55
|
+
metadata: Cooklang::Metadata.new
|
56
|
+
)
|
57
|
+
|
58
|
+
expected = {
|
59
|
+
"flour" => { quantity: 125, unit: "g" },
|
60
|
+
"salt" => { quantity: 1, unit: "pinch" }
|
61
|
+
}
|
62
|
+
|
63
|
+
expect(recipe.ingredients_hash).to eq(expected)
|
64
|
+
end
|
65
|
+
|
66
|
+
it "omits nil values" do
|
67
|
+
ingredient = Cooklang::Ingredient.new(name: "salt")
|
68
|
+
|
69
|
+
recipe = described_class.new(
|
70
|
+
ingredients: [ingredient],
|
71
|
+
cookware: [],
|
72
|
+
timers: [],
|
73
|
+
steps: [],
|
74
|
+
metadata: Cooklang::Metadata.new
|
75
|
+
)
|
76
|
+
|
77
|
+
expect(recipe.ingredients_hash).to eq({ "salt" => {} })
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
describe "#steps_text" do
|
82
|
+
it "returns text representation of steps" do
|
83
|
+
step1 = Cooklang::Step.new(segments: ["Mix the ingredients"])
|
84
|
+
step2 = Cooklang::Step.new(segments: ["Cook for ", { type: :timer, name: "timer" }])
|
85
|
+
|
86
|
+
recipe = described_class.new(
|
87
|
+
ingredients: [],
|
88
|
+
cookware: [],
|
89
|
+
timers: [],
|
90
|
+
steps: [step1, step2],
|
91
|
+
metadata: Cooklang::Metadata.new
|
92
|
+
)
|
93
|
+
|
94
|
+
expect(recipe.steps_text).to eq(["Mix the ingredients", "Cook for timer"])
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
describe "#to_h" do
|
99
|
+
it "returns complete hash representation in Cook CLI format" do
|
100
|
+
recipe = described_class.new(
|
101
|
+
ingredients: [ingredient],
|
102
|
+
cookware: [cookware],
|
103
|
+
timers: [timer],
|
104
|
+
steps: [step],
|
105
|
+
metadata: metadata
|
106
|
+
)
|
107
|
+
|
108
|
+
hash = recipe.to_h
|
109
|
+
|
110
|
+
expect(hash).to have_key(:metadata)
|
111
|
+
expect(hash).to have_key(:sections)
|
112
|
+
expect(hash).to have_key(:ingredients)
|
113
|
+
expect(hash).to have_key(:cookware)
|
114
|
+
expect(hash).to have_key(:timers)
|
115
|
+
expect(hash).to have_key(:inline_quantities)
|
116
|
+
|
117
|
+
expect(hash[:metadata]).to have_key(:map)
|
118
|
+
expect(hash[:ingredients]).to be_an(Array)
|
119
|
+
expect(hash[:cookware]).to be_an(Array)
|
120
|
+
expect(hash[:timers]).to be_an(Array)
|
121
|
+
expect(hash[:sections]).to be_an(Array)
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
describe "comprehensive output tests" do
|
126
|
+
let(:recipe_text) do
|
127
|
+
<<~RECIPE
|
128
|
+
>> title: Test Recipe
|
129
|
+
>> servings: 4
|
130
|
+
>> prep_time: 15 minutes
|
131
|
+
|
132
|
+
Heat #large pot{} over medium heat.
|
133
|
+
|
134
|
+
Add @olive oil{2%tbsp} and @onion{1%medium}(diced).
|
135
|
+
Cook for ~{5%minutes} until soft.
|
136
|
+
|
137
|
+
Add @salt{} and @pepper{} to taste.
|
138
|
+
Season with @garlic{2%cloves}(minced).
|
139
|
+
RECIPE
|
140
|
+
end
|
141
|
+
|
142
|
+
let(:parsed_recipe) { Cooklang.parse(recipe_text) }
|
143
|
+
|
144
|
+
let(:expected_hash) do
|
145
|
+
{
|
146
|
+
metadata: { map: { "title" => "Test Recipe", "servings" => "4", "prep_time" => "15 minutes" } },
|
147
|
+
sections: [
|
148
|
+
{
|
149
|
+
name: nil,
|
150
|
+
content: [
|
151
|
+
{ type: "step", value: { items: [{ type: "text", value: "Heat " }, { type: "cookware", index: 0 }, { type: "text", value: " over medium heat." }], number: 1 } },
|
152
|
+
{ type: "step", value: { items: [{ type: "text", value: "Add " }, { type: "ingredient", index: 0 }, { type: "text", value: " and " }, { type: "ingredient", index: 1 }, { type: "text", value: "." }, { type: "text", value: "\n" }, { type: "text", value: "Cook for " }, { type: "timer", index: 0 }, { type: "text", value: " until soft." }], number: 2 } },
|
153
|
+
{ type: "step", value: { items: [{ type: "text", value: "Add " }, { type: "ingredient", index: 2 }, { type: "text", value: " and " }, { type: "ingredient", index: 3 }, { type: "text", value: " to taste." }, { type: "text", value: "\n" }, { type: "text", value: "Season with " }, { type: "ingredient", index: 4 }, { type: "text", value: "." }], number: 3 } }
|
154
|
+
]
|
155
|
+
}
|
156
|
+
],
|
157
|
+
ingredients: [
|
158
|
+
{ name: "olive oil", alias: nil, quantity: { value: { type: "number", value: { type: "regular", value: 2.0 } }, unit: "tbsp" }, note: nil, reference: nil, relation: { type: "definition", referenced_from: [], defined_in_step: true, reference_target: nil }, modifiers: "" },
|
159
|
+
{ name: "onion", alias: nil, quantity: { value: { type: "number", value: { type: "regular", value: 1.0 } }, unit: "medium" }, note: "diced", reference: nil, relation: { type: "definition", referenced_from: [], defined_in_step: true, reference_target: nil }, modifiers: "" },
|
160
|
+
{ name: "salt", alias: nil, quantity: nil, note: nil, reference: nil, relation: { type: "definition", referenced_from: [], defined_in_step: true, reference_target: nil }, modifiers: "" },
|
161
|
+
{ name: "pepper", alias: nil, quantity: nil, note: nil, reference: nil, relation: { type: "definition", referenced_from: [], defined_in_step: true, reference_target: nil }, modifiers: "" },
|
162
|
+
{ name: "garlic", alias: nil, quantity: { value: { type: "number", value: { type: "regular", value: 2.0 } }, unit: "cloves" }, note: "minced", reference: nil, relation: { type: "definition", referenced_from: [], defined_in_step: true, reference_target: nil }, modifiers: "" }
|
163
|
+
],
|
164
|
+
cookware: [
|
165
|
+
{ name: "large pot", alias: nil, quantity: nil, note: nil, relation: { type: "definition", referenced_from: [], defined_in_step: true }, modifiers: "" }
|
166
|
+
],
|
167
|
+
timers: [
|
168
|
+
{ name: nil, quantity: { value: { type: "number", value: { type: "regular", value: 5.0 } }, unit: "minutes" } }
|
169
|
+
],
|
170
|
+
inline_quantities: [],
|
171
|
+
data: { type: "Scaled", target: { factor: 1.0 }, ingredients: ["scaled", "scaled", "noQuantity", "noQuantity", "scaled"], cookware: ["noQuantity"], timers: ["fixed"] }
|
172
|
+
}
|
173
|
+
end
|
174
|
+
|
175
|
+
let(:expected_json) do
|
176
|
+
{
|
177
|
+
"metadata" => { "map" => { "title" => "Test Recipe", "servings" => "4", "prep_time" => "15 minutes" } },
|
178
|
+
"sections" => [
|
179
|
+
{
|
180
|
+
"name" => nil,
|
181
|
+
"content" => [
|
182
|
+
{ "type" => "step", "value" => { "items" => [{ "type" => "text", "value" => "Heat " }, { "type" => "cookware", "index" => 0 }, { "type" => "text", "value" => " over medium heat." }], "number" => 1 } },
|
183
|
+
{ "type" => "step", "value" => { "items" => [{ "type" => "text", "value" => "Add " }, { "type" => "ingredient", "index" => 0 }, { "type" => "text", "value" => " and " }, { "type" => "ingredient", "index" => 1 }, { "type" => "text", "value" => "." }, { "type" => "text", "value" => "\n" }, { "type" => "text", "value" => "Cook for " }, { "type" => "timer", "index" => 0 }, { "type" => "text", "value" => " until soft." }], "number" => 2 } },
|
184
|
+
{ "type" => "step", "value" => { "items" => [{ "type" => "text", "value" => "Add " }, { "type" => "ingredient", "index" => 2 }, { "type" => "text", "value" => " and " }, { "type" => "ingredient", "index" => 3 }, { "type" => "text", "value" => " to taste." }, { "type" => "text", "value" => "\n" }, { "type" => "text", "value" => "Season with " }, { "type" => "ingredient", "index" => 4 }, { "type" => "text", "value" => "." }], "number" => 3 } }
|
185
|
+
]
|
186
|
+
}
|
187
|
+
],
|
188
|
+
"ingredients" => [
|
189
|
+
{ "name" => "olive oil", "alias" => nil, "quantity" => { "value" => { "type" => "number", "value" => { "type" => "regular", "value" => 2.0 } }, "unit" => "tbsp" }, "note" => nil, "reference" => nil, "relation" => { "type" => "definition", "referenced_from" => [], "defined_in_step" => true, "reference_target" => nil }, "modifiers" => "" },
|
190
|
+
{ "name" => "onion", "alias" => nil, "quantity" => { "value" => { "type" => "number", "value" => { "type" => "regular", "value" => 1.0 } }, "unit" => "medium" }, "note" => "diced", "reference" => nil, "relation" => { "type" => "definition", "referenced_from" => [], "defined_in_step" => true, "reference_target" => nil }, "modifiers" => "" },
|
191
|
+
{ "name" => "salt", "alias" => nil, "quantity" => nil, "note" => nil, "reference" => nil, "relation" => { "type" => "definition", "referenced_from" => [], "defined_in_step" => true, "reference_target" => nil }, "modifiers" => "" },
|
192
|
+
{ "name" => "pepper", "alias" => nil, "quantity" => nil, "note" => nil, "reference" => nil, "relation" => { "type" => "definition", "referenced_from" => [], "defined_in_step" => true, "reference_target" => nil }, "modifiers" => "" },
|
193
|
+
{ "name" => "garlic", "alias" => nil, "quantity" => { "value" => { "type" => "number", "value" => { "type" => "regular", "value" => 2.0 } }, "unit" => "cloves" }, "note" => "minced", "reference" => nil, "relation" => { "type" => "definition", "referenced_from" => [], "defined_in_step" => true, "reference_target" => nil }, "modifiers" => "" }
|
194
|
+
],
|
195
|
+
"cookware" => [
|
196
|
+
{ "name" => "large pot", "alias" => nil, "quantity" => nil, "note" => nil, "relation" => { "type" => "definition", "referenced_from" => [], "defined_in_step" => true }, "modifiers" => "" }
|
197
|
+
],
|
198
|
+
"timers" => [
|
199
|
+
{ "name" => nil, "quantity" => { "value" => { "type" => "number", "value" => { "type" => "regular", "value" => 5.0 } }, "unit" => "minutes" } }
|
200
|
+
],
|
201
|
+
"inline_quantities" => [],
|
202
|
+
"data" => { "type" => "Scaled", "target" => { "factor" => 1.0 }, "ingredients" => ["scaled", "scaled", "noQuantity", "noQuantity", "scaled"], "cookware" => ["noQuantity"], "timers" => ["fixed"] }
|
203
|
+
}
|
204
|
+
end
|
205
|
+
|
206
|
+
describe "#to_h" do
|
207
|
+
it "returns complete canonical structure" do
|
208
|
+
expect(parsed_recipe.to_h).to eq(expected_hash)
|
209
|
+
end
|
210
|
+
end
|
211
|
+
|
212
|
+
describe "#to_json" do
|
213
|
+
it "returns JSON with null for canonical defaults" do
|
214
|
+
expect(JSON.parse(parsed_recipe.to_json)).to eq(expected_json)
|
215
|
+
end
|
216
|
+
end
|
217
|
+
|
218
|
+
describe "canonical vs JSON consistency" do
|
219
|
+
it "maintains different representations for same recipe" do
|
220
|
+
# Internal canonical representation keeps defaults
|
221
|
+
expect(parsed_recipe.ingredients.find { |i| i.name == "salt" }.quantity).to eq("some")
|
222
|
+
expect(parsed_recipe.cookware.first.quantity).to eq(1)
|
223
|
+
|
224
|
+
# Both hash and JSON output convert defaults to nil for external use
|
225
|
+
hash = parsed_recipe.to_h
|
226
|
+
json_parsed = JSON.parse(parsed_recipe.to_json)
|
227
|
+
|
228
|
+
salt_hash = hash[:ingredients].find { |i| i[:name] == "salt" }
|
229
|
+
salt_json = json_parsed["ingredients"].find { |i| i["name"] == "salt" }
|
230
|
+
expect(salt_hash[:quantity]).to be_nil
|
231
|
+
expect(salt_json["quantity"]).to be_nil
|
232
|
+
|
233
|
+
expect(hash[:cookware].first[:quantity]).to be_nil
|
234
|
+
expect(json_parsed["cookware"].first["quantity"]).to be_nil
|
235
|
+
end
|
236
|
+
end
|
237
|
+
end
|
238
|
+
|
239
|
+
describe "edge cases" do
|
240
|
+
it "handles empty recipe" do
|
241
|
+
recipe = described_class.new(
|
242
|
+
ingredients: [],
|
243
|
+
cookware: [],
|
244
|
+
timers: [],
|
245
|
+
steps: [],
|
246
|
+
metadata: Cooklang::Metadata.new
|
247
|
+
)
|
248
|
+
|
249
|
+
hash = recipe.to_h
|
250
|
+
expect(hash[:ingredients]).to eq([])
|
251
|
+
expect(hash[:cookware]).to eq([])
|
252
|
+
expect(hash[:timers]).to eq([])
|
253
|
+
expect(hash[:sections].first[:content]).to eq([])
|
254
|
+
|
255
|
+
json = JSON.parse(recipe.to_json)
|
256
|
+
expect(json["ingredients"]).to eq([])
|
257
|
+
expect(json["cookware"]).to eq([])
|
258
|
+
expect(json["timers"]).to eq([])
|
259
|
+
end
|
260
|
+
|
261
|
+
it "handles ingredients with no quantity specified" do
|
262
|
+
ingredient = Cooklang::Ingredient.new(name: "salt")
|
263
|
+
recipe = described_class.new(
|
264
|
+
ingredients: [ingredient],
|
265
|
+
cookware: [],
|
266
|
+
timers: [],
|
267
|
+
steps: [],
|
268
|
+
metadata: Cooklang::Metadata.new
|
269
|
+
)
|
270
|
+
|
271
|
+
hash = recipe.to_h
|
272
|
+
salt = hash[:ingredients].first
|
273
|
+
expect(salt[:quantity]).to be_nil
|
274
|
+
|
275
|
+
json = JSON.parse(recipe.to_json)
|
276
|
+
salt_json = json["ingredients"].first
|
277
|
+
expect(salt_json["quantity"]).to be_nil
|
278
|
+
end
|
279
|
+
|
280
|
+
it "handles cookware with no quantity specified" do
|
281
|
+
cookware = Cooklang::Cookware.new(name: "pan")
|
282
|
+
recipe = described_class.new(
|
283
|
+
ingredients: [],
|
284
|
+
cookware: [cookware],
|
285
|
+
timers: [],
|
286
|
+
steps: [],
|
287
|
+
metadata: Cooklang::Metadata.new
|
288
|
+
)
|
289
|
+
|
290
|
+
hash = recipe.to_h
|
291
|
+
pan = hash[:cookware].first
|
292
|
+
expect(pan[:quantity]).to be_nil
|
293
|
+
|
294
|
+
json = JSON.parse(recipe.to_json)
|
295
|
+
pan_json = json["cookware"].first
|
296
|
+
expect(pan_json["quantity"]).to be_nil
|
297
|
+
end
|
298
|
+
|
299
|
+
it "handles fractional quantities" do
|
300
|
+
ingredient = Cooklang::Ingredient.new(name: "flour", quantity: 1.5, unit: "cups")
|
301
|
+
recipe = described_class.new(
|
302
|
+
ingredients: [ingredient],
|
303
|
+
cookware: [],
|
304
|
+
timers: [],
|
305
|
+
steps: [],
|
306
|
+
metadata: Cooklang::Metadata.new
|
307
|
+
)
|
308
|
+
|
309
|
+
hash = recipe.to_h
|
310
|
+
flour = hash[:ingredients].first
|
311
|
+
expect(flour[:quantity][:value][:value][:value]).to eq(1.5)
|
312
|
+
expect(flour[:quantity][:unit]).to eq("cups")
|
313
|
+
|
314
|
+
json = JSON.parse(recipe.to_json)
|
315
|
+
flour_json = json["ingredients"].first
|
316
|
+
expect(flour_json["quantity"]["value"]["value"]["value"]).to eq(1.5)
|
317
|
+
expect(flour_json["quantity"]["unit"]).to eq("cups")
|
318
|
+
end
|
319
|
+
end
|
320
|
+
|
321
|
+
describe "#==" do
|
322
|
+
it "returns true for recipes with same content" do
|
323
|
+
recipe1 = described_class.new(
|
324
|
+
ingredients: [ingredient],
|
325
|
+
cookware: [cookware],
|
326
|
+
timers: [timer],
|
327
|
+
steps: [step],
|
328
|
+
metadata: metadata
|
329
|
+
)
|
330
|
+
|
331
|
+
recipe2 = described_class.new(
|
332
|
+
ingredients: [ingredient],
|
333
|
+
cookware: [cookware],
|
334
|
+
timers: [timer],
|
335
|
+
steps: [step],
|
336
|
+
metadata: metadata
|
337
|
+
)
|
338
|
+
|
339
|
+
expect(recipe1).to eq(recipe2)
|
340
|
+
end
|
341
|
+
|
342
|
+
it "returns false for recipes with different content" do
|
343
|
+
recipe1 = described_class.new(
|
344
|
+
ingredients: [ingredient],
|
345
|
+
cookware: [],
|
346
|
+
timers: [],
|
347
|
+
steps: [],
|
348
|
+
metadata: Cooklang::Metadata.new
|
349
|
+
)
|
350
|
+
|
351
|
+
recipe2 = described_class.new(
|
352
|
+
ingredients: [],
|
353
|
+
cookware: [cookware],
|
354
|
+
timers: [],
|
355
|
+
steps: [],
|
356
|
+
metadata: Cooklang::Metadata.new
|
357
|
+
)
|
358
|
+
|
359
|
+
expect(recipe1).not_to eq(recipe2)
|
360
|
+
end
|
361
|
+
|
362
|
+
it "returns false for non-Recipe objects" do
|
363
|
+
recipe = described_class.new(
|
364
|
+
ingredients: [],
|
365
|
+
cookware: [],
|
366
|
+
timers: [],
|
367
|
+
steps: [],
|
368
|
+
metadata: Cooklang::Metadata.new
|
369
|
+
)
|
370
|
+
|
371
|
+
expect(recipe).not_to eq("not a recipe")
|
372
|
+
end
|
373
|
+
end
|
374
|
+
end
|