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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +35 -0
  3. data/.gitignore +12 -0
  4. data/.qlty/.gitignore +7 -0
  5. data/.qlty/configs/.yamllint.yaml +21 -0
  6. data/.qlty/qlty.toml +101 -0
  7. data/.rspec +3 -0
  8. data/.rubocop.yml +340 -0
  9. data/Gemfile +6 -0
  10. data/Gemfile.lock +84 -0
  11. data/README.md +197 -17
  12. data/Rakefile +12 -0
  13. data/cooklang.gemspec +35 -0
  14. data/lib/cooklang/builders/recipe_builder.rb +60 -0
  15. data/lib/cooklang/builders/step_builder.rb +74 -0
  16. data/lib/cooklang/formatter.rb +6 -2
  17. data/lib/cooklang/formatters/hash.rb +257 -0
  18. data/lib/cooklang/formatters/json.rb +13 -0
  19. data/lib/cooklang/formatters/text.rb +1 -3
  20. data/lib/cooklang/lexer.rb +5 -14
  21. data/lib/cooklang/parser.rb +18 -653
  22. data/lib/cooklang/parsers/cookware_parser.rb +130 -0
  23. data/lib/cooklang/parsers/ingredient_parser.rb +176 -0
  24. data/lib/cooklang/parsers/timer_parser.rb +132 -0
  25. data/lib/cooklang/processors/element_parser.rb +40 -0
  26. data/lib/cooklang/processors/metadata_processor.rb +127 -0
  27. data/lib/cooklang/processors/step_processor.rb +204 -0
  28. data/lib/cooklang/processors/token_processor.rb +101 -0
  29. data/lib/cooklang/recipe.rb +31 -24
  30. data/lib/cooklang/step.rb +12 -2
  31. data/lib/cooklang/timer.rb +3 -1
  32. data/lib/cooklang/token_stream.rb +130 -0
  33. data/lib/cooklang/version.rb +1 -1
  34. data/lib/cooklang.rb +31 -3
  35. data/spec/comprehensive_spec.rb +179 -0
  36. data/spec/cooklang_spec.rb +38 -0
  37. data/spec/fixtures/canonical.yaml +837 -0
  38. data/spec/formatters/text_spec.rb +189 -0
  39. data/spec/integration/canonical_spec.rb +211 -0
  40. data/spec/lexer_spec.rb +357 -0
  41. data/spec/models/cookware_spec.rb +116 -0
  42. data/spec/models/ingredient_spec.rb +192 -0
  43. data/spec/models/metadata_spec.rb +241 -0
  44. data/spec/models/note_spec.rb +65 -0
  45. data/spec/models/recipe_spec.rb +374 -0
  46. data/spec/models/section_spec.rb +65 -0
  47. data/spec/models/step_spec.rb +236 -0
  48. data/spec/models/timer_spec.rb +173 -0
  49. data/spec/parser_spec.rb +398 -0
  50. data/spec/spec_helper.rb +23 -0
  51. data/spec/token_stream_spec.rb +278 -0
  52. 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