cooklang 1.0.0 → 1.0.1

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 (47) 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 +10 -5
  12. data/Rakefile +12 -0
  13. data/cooklang.gemspec +35 -0
  14. data/lib/cooklang/builders/recipe_builder.rb +64 -0
  15. data/lib/cooklang/builders/step_builder.rb +76 -0
  16. data/lib/cooklang/lexer.rb +5 -14
  17. data/lib/cooklang/parser.rb +24 -653
  18. data/lib/cooklang/parsers/cookware_parser.rb +133 -0
  19. data/lib/cooklang/parsers/ingredient_parser.rb +179 -0
  20. data/lib/cooklang/parsers/timer_parser.rb +135 -0
  21. data/lib/cooklang/processors/element_parser.rb +45 -0
  22. data/lib/cooklang/processors/metadata_processor.rb +129 -0
  23. data/lib/cooklang/processors/step_processor.rb +208 -0
  24. data/lib/cooklang/processors/token_processor.rb +104 -0
  25. data/lib/cooklang/recipe.rb +25 -15
  26. data/lib/cooklang/step.rb +12 -2
  27. data/lib/cooklang/timer.rb +3 -1
  28. data/lib/cooklang/token_stream.rb +130 -0
  29. data/lib/cooklang/version.rb +1 -1
  30. data/spec/comprehensive_spec.rb +179 -0
  31. data/spec/cooklang_spec.rb +38 -0
  32. data/spec/fixtures/canonical.yaml +837 -0
  33. data/spec/formatters/text_spec.rb +189 -0
  34. data/spec/integration/canonical_spec.rb +211 -0
  35. data/spec/lexer_spec.rb +357 -0
  36. data/spec/models/cookware_spec.rb +116 -0
  37. data/spec/models/ingredient_spec.rb +192 -0
  38. data/spec/models/metadata_spec.rb +241 -0
  39. data/spec/models/note_spec.rb +65 -0
  40. data/spec/models/recipe_spec.rb +171 -0
  41. data/spec/models/section_spec.rb +65 -0
  42. data/spec/models/step_spec.rb +236 -0
  43. data/spec/models/timer_spec.rb +173 -0
  44. data/spec/parser_spec.rb +398 -0
  45. data/spec/spec_helper.rb +23 -0
  46. data/spec/token_stream_spec.rb +278 -0
  47. metadata +162 -6
@@ -0,0 +1,357 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Cooklang::Lexer do
6
+ describe "#tokenize" do
7
+ context "with empty input" do
8
+ it "returns empty array" do
9
+ lexer = described_class.new("")
10
+ tokens = lexer.tokenize
11
+ expect(tokens).to be_empty
12
+ end
13
+ end
14
+
15
+ context "with plain text" do
16
+ it "tokenizes simple text" do
17
+ lexer = described_class.new("Hello world")
18
+ tokens = lexer.tokenize
19
+
20
+ expect(tokens.length).to eq(1)
21
+ expect(tokens[0].type).to eq(:text)
22
+ expect(tokens[0].value).to eq("Hello world")
23
+ end
24
+
25
+ it "handles text with spaces" do
26
+ lexer = described_class.new("Mix the ingredients well")
27
+ tokens = lexer.tokenize
28
+
29
+ expect(tokens.length).to eq(1)
30
+ expect(tokens[0].type).to eq(:text)
31
+ expect(tokens[0].value).to eq("Mix the ingredients well")
32
+ end
33
+ end
34
+
35
+ context "with ingredient markers" do
36
+ it "tokenizes simple ingredient" do
37
+ lexer = described_class.new("@salt")
38
+ tokens = lexer.tokenize
39
+
40
+ expect(tokens.length).to eq(2)
41
+ expect(tokens[0].type).to eq(:ingredient_marker)
42
+ expect(tokens[0].value).to eq("@")
43
+ expect(tokens[1].type).to eq(:text)
44
+ expect(tokens[1].value).to eq("salt")
45
+ end
46
+
47
+ it "tokenizes ingredient with braces" do
48
+ lexer = described_class.new("@flour{125%g}")
49
+ tokens = lexer.tokenize
50
+
51
+ expect(tokens.map(&:type)).to eq(%i[
52
+ ingredient_marker text open_brace text percent text close_brace
53
+ ])
54
+ expect(tokens.map(&:value)).to eq(["@", "flour", "{", "125", "%", "g", "}"])
55
+ end
56
+
57
+ it "tokenizes ingredient with parentheses" do
58
+ lexer = described_class.new("@onion(diced)")
59
+ tokens = lexer.tokenize
60
+
61
+ expect(tokens.map(&:type)).to eq(%i[
62
+ ingredient_marker text open_paren text close_paren
63
+ ])
64
+ expect(tokens.map(&:value)).to eq(["@", "onion", "(", "diced", ")"])
65
+ end
66
+ end
67
+
68
+ context "with cookware markers" do
69
+ it "tokenizes simple cookware" do
70
+ lexer = described_class.new("#pan")
71
+ tokens = lexer.tokenize
72
+
73
+ expect(tokens.length).to eq(2)
74
+ expect(tokens[0].type).to eq(:cookware_marker)
75
+ expect(tokens[0].value).to eq("#")
76
+ expect(tokens[1].type).to eq(:text)
77
+ expect(tokens[1].value).to eq("pan")
78
+ end
79
+
80
+ it "tokenizes cookware with braces" do
81
+ lexer = described_class.new("#frying pan{1}")
82
+ tokens = lexer.tokenize
83
+
84
+ expect(tokens.map(&:type)).to eq(%i[
85
+ cookware_marker text open_brace text close_brace
86
+ ])
87
+ expect(tokens.map(&:value)).to eq(["#", "frying pan", "{", "1", "}"])
88
+ end
89
+ end
90
+
91
+ context "with timer markers" do
92
+ it "tokenizes simple timer" do
93
+ lexer = described_class.new("~{10%minutes}")
94
+ tokens = lexer.tokenize
95
+
96
+ expect(tokens.map(&:type)).to eq(%i[
97
+ timer_marker open_brace text percent text close_brace
98
+ ])
99
+ expect(tokens.map(&:value)).to eq(["~", "{", "10", "%", "minutes", "}"])
100
+ end
101
+
102
+ it "tokenizes named timer" do
103
+ lexer = described_class.new("~prep{5%minutes}")
104
+ tokens = lexer.tokenize
105
+
106
+ expect(tokens.map(&:type)).to eq(%i[
107
+ timer_marker text open_brace text percent text close_brace
108
+ ])
109
+ expect(tokens.map(&:value)).to eq(["~", "prep", "{", "5", "%", "minutes", "}"])
110
+ end
111
+ end
112
+
113
+ context "with comments" do
114
+ it "tokenizes line comments" do
115
+ lexer = described_class.new("-- This is a comment")
116
+ tokens = lexer.tokenize
117
+
118
+ expect(tokens.map(&:type)).to eq(%i[comment_line text])
119
+ expect(tokens.map(&:value)).to eq(["--", " This is a comment"])
120
+ end
121
+
122
+ it "tokenizes block comments" do
123
+ lexer = described_class.new("[- This is a block comment -]")
124
+ tokens = lexer.tokenize
125
+
126
+ expect(tokens.map(&:type)).to eq(%i[comment_block_start text comment_block_end])
127
+ expect(tokens.map(&:value)).to eq(["[-", " This is a block comment ", "-]"])
128
+ end
129
+
130
+ it "handles incomplete block comments" do
131
+ lexer = described_class.new("[- Incomplete comment")
132
+ tokens = lexer.tokenize
133
+
134
+ expect(tokens.map(&:type)).to eq(%i[comment_block_start text])
135
+ expect(tokens.map(&:value)).to eq(["[-", " Incomplete comment"])
136
+ end
137
+ end
138
+
139
+ context "with metadata markers" do
140
+ it "tokenizes metadata marker" do
141
+ lexer = described_class.new(">> servings: 4")
142
+ tokens = lexer.tokenize
143
+
144
+ expect(tokens.map(&:type)).to eq(%i[metadata_marker text])
145
+ expect(tokens.map(&:value)).to eq([">>", " servings: 4"])
146
+ end
147
+ end
148
+
149
+ context "with section markers" do
150
+ it "tokenizes single equals" do
151
+ lexer = described_class.new("= Dough")
152
+ tokens = lexer.tokenize
153
+
154
+ expect(tokens.map(&:type)).to eq(%i[section_marker text])
155
+ expect(tokens.map(&:value)).to eq(["=", " Dough"])
156
+ end
157
+
158
+ it "tokenizes double equals" do
159
+ lexer = described_class.new("== Filling ==")
160
+ tokens = lexer.tokenize
161
+
162
+ expect(tokens.map(&:type)).to eq(%i[section_marker text section_marker])
163
+ expect(tokens.map(&:value)).to eq(["==", " Filling ", "=="])
164
+ end
165
+
166
+ it "tokenizes multiple equals" do
167
+ lexer = described_class.new("=== Section ===")
168
+ tokens = lexer.tokenize
169
+
170
+ expect(tokens.map(&:type)).to eq(%i[section_marker text section_marker])
171
+ expect(tokens.map(&:value)).to eq(["===", " Section ", "==="])
172
+ end
173
+ end
174
+
175
+ context "with note markers" do
176
+ it "tokenizes note marker" do
177
+ lexer = described_class.new("> Don't burn the roux!")
178
+ tokens = lexer.tokenize
179
+
180
+ expect(tokens.map(&:type)).to eq(%i[note_marker text])
181
+ expect(tokens.map(&:value)).to eq([">", " Don't burn the roux!"])
182
+ end
183
+
184
+ it "does not tokenize > in metadata marker >>" do
185
+ lexer = described_class.new(">> servings: 4")
186
+ tokens = lexer.tokenize
187
+
188
+ expect(tokens.map(&:type)).to eq(%i[metadata_marker text])
189
+ expect(tokens.map(&:value)).to eq([">>", " servings: 4"])
190
+ end
191
+ end
192
+
193
+ context "with YAML delimiters" do
194
+ it "tokenizes YAML delimiter" do
195
+ lexer = described_class.new("---")
196
+ tokens = lexer.tokenize
197
+
198
+ expect(tokens.length).to eq(1)
199
+ expect(tokens[0].type).to eq(:yaml_delimiter)
200
+ expect(tokens[0].value).to eq("---")
201
+ end
202
+ end
203
+
204
+ context "with newlines" do
205
+ it "tokenizes newlines" do
206
+ lexer = described_class.new("Line 1\nLine 2")
207
+ tokens = lexer.tokenize
208
+
209
+ expect(tokens.map(&:type)).to eq(%i[text newline text])
210
+ expect(tokens.map(&:value)).to eq(["Line 1", "\n", "Line 2"])
211
+ end
212
+
213
+ it "tracks line numbers correctly" do
214
+ lexer = described_class.new("Line 1\nLine 2\nLine 3")
215
+ tokens = lexer.tokenize
216
+
217
+ expect(tokens[0].line).to eq(1) # "Line 1"
218
+ expect(tokens[1].line).to eq(1) # "\n"
219
+ expect(tokens[2].line).to eq(2) # "Line 2"
220
+ expect(tokens[3].line).to eq(2) # "\n"
221
+ expect(tokens[4].line).to eq(3) # "Line 3"
222
+ end
223
+ end
224
+
225
+ context "with complex recipes" do
226
+ it "tokenizes a complete recipe step" do
227
+ input = "Mix @flour{125%g} with @milk{250%ml} in a #bowl{1}."
228
+ lexer = described_class.new(input)
229
+ tokens = lexer.tokenize
230
+
231
+ expected_types = %i[
232
+ text ingredient_marker text open_brace text percent text close_brace
233
+ text ingredient_marker text open_brace text percent text close_brace
234
+ text cookware_marker text open_brace text close_brace text
235
+ ]
236
+
237
+ expect(tokens.map(&:type)).to eq(expected_types)
238
+ end
239
+
240
+ it "tokenizes recipe with timer and comments" do
241
+ input = "Cook for ~{10%minutes} -- until golden\nServe hot"
242
+ lexer = described_class.new(input)
243
+ tokens = lexer.tokenize
244
+
245
+ expected_values = [
246
+ "Cook for ", "~", "{", "10", "%", "minutes", "}", " ",
247
+ "--", " until golden", "\n", "Serve hot"
248
+ ]
249
+
250
+ expect(tokens.map(&:value)).to eq(expected_values)
251
+ end
252
+ end
253
+
254
+ context "with position tracking" do
255
+ it "tracks position correctly" do
256
+ lexer = described_class.new("@salt")
257
+ tokens = lexer.tokenize
258
+
259
+ expect(tokens[0].position).to eq(0) # '@' at position 0
260
+ expect(tokens[1].position).to eq(1) # 'salt' at position 1
261
+ end
262
+
263
+ it "tracks column numbers correctly" do
264
+ lexer = described_class.new("@salt #pan")
265
+ tokens = lexer.tokenize
266
+
267
+ expect(tokens[0].column).to eq(1) # '@'
268
+ expect(tokens[1].column).to eq(2) # 'salt '
269
+ expect(tokens[2].column).to eq(7) # '#'
270
+ expect(tokens[3].column).to eq(8) # 'pan'
271
+ end
272
+ end
273
+
274
+ context "with edge cases" do
275
+ it "handles consecutive special characters" do
276
+ lexer = described_class.new("@#~{}()")
277
+ tokens = lexer.tokenize
278
+
279
+ expected_types = %i[
280
+ ingredient_marker cookware_marker timer_marker
281
+ open_brace close_brace open_paren close_paren
282
+ ]
283
+
284
+ expect(tokens.map(&:type)).to eq(expected_types)
285
+ end
286
+
287
+ it "handles text between special characters" do
288
+ lexer = described_class.new("Heat @oil in #pan for ~{2%min}")
289
+ tokens = lexer.tokenize
290
+
291
+ expect(tokens.filter_map { |t| t.value if t.type == :text }).to eq([
292
+ "Heat ", "oil in ", "pan for ", "2", "min"
293
+ ])
294
+ end
295
+
296
+ it "handles empty braces and parentheses" do
297
+ lexer = described_class.new("@ingredient{} #cookware() ~timer{}")
298
+ tokens = lexer.tokenize
299
+
300
+ ingredient_tokens = tokens[0..3]
301
+ expect(ingredient_tokens.map(&:type)).to eq(%i[
302
+ ingredient_marker text open_brace close_brace
303
+ ])
304
+ end
305
+ end
306
+
307
+ context "with whitespace handling" do
308
+ it "preserves whitespace in text" do
309
+ lexer = described_class.new(" Mix well ")
310
+ tokens = lexer.tokenize
311
+
312
+ expect(tokens.length).to eq(1)
313
+ expect(tokens[0].value).to eq(" Mix well ")
314
+ end
315
+
316
+ it "handles tabs and spaces" do
317
+ lexer = described_class.new("\tMix @salt ")
318
+ tokens = lexer.tokenize
319
+
320
+ expect(tokens[0].value).to eq("\tMix ")
321
+ expect(tokens[2].value).to eq("salt ")
322
+ end
323
+ end
324
+
325
+ context "with unrecognized characters" do
326
+ it "handles unrecognized characters gracefully" do
327
+ # Test the else branch in tokenize that handles unrecognized characters
328
+ lexer = described_class.new("Mix \x01\x02 salt")
329
+ tokens = lexer.tokenize
330
+
331
+ # Should skip unrecognized characters and continue tokenizing
332
+ expect(tokens.map(&:type)).to eq(%i[text text])
333
+ expect(tokens.map(&:value)).to eq(["Mix ", " salt"])
334
+ end
335
+ end
336
+ end
337
+
338
+ describe "Token" do
339
+ it "creates token with all attributes" do
340
+ token = Cooklang::Token.new(:text, "hello", 5, 2, 10)
341
+
342
+ expect(token.type).to eq(:text)
343
+ expect(token.value).to eq("hello")
344
+ expect(token.position).to eq(5)
345
+ expect(token.line).to eq(2)
346
+ expect(token.column).to eq(10)
347
+ end
348
+
349
+ it "creates token with default position values" do
350
+ token = Cooklang::Token.new(:text, "hello")
351
+
352
+ expect(token.position).to eq(0)
353
+ expect(token.line).to eq(1)
354
+ expect(token.column).to eq(1)
355
+ end
356
+ end
357
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Cooklang::Cookware do
6
+ describe "#initialize" do
7
+ it "creates cookware with name and quantity" do
8
+ cookware = described_class.new(name: "frying pan", quantity: 2)
9
+
10
+ expect(cookware.name).to eq("frying pan")
11
+ expect(cookware.quantity).to eq(2)
12
+ end
13
+
14
+ it "creates cookware with only name" do
15
+ cookware = described_class.new(name: "bowl")
16
+
17
+ expect(cookware.name).to eq("bowl")
18
+ expect(cookware.quantity).to be_nil
19
+ end
20
+
21
+ it "converts name to string and freezes it" do
22
+ cookware = described_class.new(name: :pan)
23
+
24
+ expect(cookware.name).to eq("pan")
25
+ expect(cookware.name).to be_frozen
26
+ end
27
+ end
28
+
29
+ describe "#to_s" do
30
+ it "returns name only when no quantity" do
31
+ cookware = described_class.new(name: "bowl")
32
+ expect(cookware.to_s).to eq("bowl")
33
+ end
34
+
35
+ it "includes quantity when present" do
36
+ cookware = described_class.new(name: "frying pan", quantity: 2)
37
+ expect(cookware.to_s).to eq("frying pan (2)")
38
+ end
39
+ end
40
+
41
+ describe "#to_h" do
42
+ it "returns hash with all present attributes" do
43
+ cookware = described_class.new(name: "frying pan", quantity: 2)
44
+
45
+ expected = {
46
+ name: "frying pan",
47
+ quantity: 2
48
+ }
49
+
50
+ expect(cookware.to_h).to eq(expected)
51
+ end
52
+
53
+ it "omits nil attributes" do
54
+ cookware = described_class.new(name: "bowl")
55
+
56
+ expect(cookware.to_h).to eq({ name: "bowl" })
57
+ end
58
+ end
59
+
60
+ describe "#==" do
61
+ it "returns true for cookware with same attributes" do
62
+ cookware1 = described_class.new(name: "pan", quantity: 1)
63
+ cookware2 = described_class.new(name: "pan", quantity: 1)
64
+
65
+ expect(cookware1).to eq(cookware2)
66
+ end
67
+
68
+ it "returns false for cookware with different names" do
69
+ cookware1 = described_class.new(name: "pan")
70
+ cookware2 = described_class.new(name: "bowl")
71
+
72
+ expect(cookware1).not_to eq(cookware2)
73
+ end
74
+
75
+ it "returns false for cookware with different quantities" do
76
+ cookware1 = described_class.new(name: "pan", quantity: 1)
77
+ cookware2 = described_class.new(name: "pan", quantity: 2)
78
+
79
+ expect(cookware1).not_to eq(cookware2)
80
+ end
81
+
82
+ it "returns false for non-Cookware objects" do
83
+ cookware = described_class.new(name: "pan")
84
+
85
+ expect(cookware).not_to eq("pan")
86
+ end
87
+ end
88
+
89
+ describe "#has_quantity?" do
90
+ it "returns true when quantity is present" do
91
+ cookware = described_class.new(name: "pan", quantity: 1)
92
+ expect(cookware).to have_quantity
93
+ end
94
+
95
+ it "returns false when quantity is nil" do
96
+ cookware = described_class.new(name: "pan")
97
+ expect(cookware).not_to have_quantity
98
+ end
99
+ end
100
+
101
+ describe "#hash" do
102
+ it "generates same hash for equal cookware" do
103
+ cookware1 = described_class.new(name: "pan", quantity: 1)
104
+ cookware2 = described_class.new(name: "pan", quantity: 1)
105
+
106
+ expect(cookware1.hash).to eq(cookware2.hash)
107
+ end
108
+
109
+ it "generates different hash for different cookware" do
110
+ cookware1 = described_class.new(name: "pan")
111
+ cookware2 = described_class.new(name: "bowl")
112
+
113
+ expect(cookware1.hash).not_to eq(cookware2.hash)
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Cooklang::Ingredient do
6
+ describe "#initialize" do
7
+ it "creates an ingredient with all attributes" do
8
+ ingredient = described_class.new(
9
+ name: "flour",
10
+ quantity: 125,
11
+ unit: "g",
12
+ notes: "sifted"
13
+ )
14
+
15
+ expect(ingredient.name).to eq("flour")
16
+ expect(ingredient.quantity).to eq(125)
17
+ expect(ingredient.unit).to eq("g")
18
+ expect(ingredient.notes).to eq("sifted")
19
+ end
20
+
21
+ it "creates an ingredient with only name" do
22
+ ingredient = described_class.new(name: "salt")
23
+
24
+ expect(ingredient.name).to eq("salt")
25
+ expect(ingredient.quantity).to be_nil
26
+ expect(ingredient.unit).to be_nil
27
+ expect(ingredient.notes).to be_nil
28
+ end
29
+
30
+ it "converts name to string and freezes it" do
31
+ ingredient = described_class.new(name: :flour)
32
+
33
+ expect(ingredient.name).to eq("flour")
34
+ expect(ingredient.name).to be_frozen
35
+ end
36
+
37
+ it "converts unit to string and freezes it" do
38
+ ingredient = described_class.new(name: "flour", unit: :grams)
39
+
40
+ expect(ingredient.unit).to eq("grams")
41
+ expect(ingredient.unit).to be_frozen
42
+ end
43
+
44
+ it "converts notes to string and freezes it" do
45
+ ingredient = described_class.new(name: "flour", notes: :sifted)
46
+
47
+ expect(ingredient.notes).to eq("sifted")
48
+ expect(ingredient.notes).to be_frozen
49
+ end
50
+ end
51
+
52
+ describe "#to_s" do
53
+ it "returns name only when no other attributes" do
54
+ ingredient = described_class.new(name: "salt")
55
+ expect(ingredient.to_s).to eq("salt")
56
+ end
57
+
58
+ it "includes quantity when present" do
59
+ ingredient = described_class.new(name: "flour", quantity: 125)
60
+ expect(ingredient.to_s).to eq("flour 125")
61
+ end
62
+
63
+ it "includes unit when present" do
64
+ ingredient = described_class.new(name: "flour", quantity: 125, unit: "g")
65
+ expect(ingredient.to_s).to eq("flour 125 g")
66
+ end
67
+
68
+ it "includes notes when present" do
69
+ ingredient = described_class.new(name: "flour", notes: "sifted")
70
+ expect(ingredient.to_s).to eq("flour (sifted)")
71
+ end
72
+
73
+ it "includes all attributes when present" do
74
+ ingredient = described_class.new(
75
+ name: "flour",
76
+ quantity: 125,
77
+ unit: "g",
78
+ notes: "sifted"
79
+ )
80
+ expect(ingredient.to_s).to eq("flour 125 g (sifted)")
81
+ end
82
+ end
83
+
84
+ describe "#to_h" do
85
+ it "returns hash with all present attributes" do
86
+ ingredient = described_class.new(
87
+ name: "flour",
88
+ quantity: 125,
89
+ unit: "g",
90
+ notes: "sifted"
91
+ )
92
+
93
+ expected = {
94
+ name: "flour",
95
+ quantity: 125,
96
+ unit: "g",
97
+ notes: "sifted"
98
+ }
99
+
100
+ expect(ingredient.to_h).to eq(expected)
101
+ end
102
+
103
+ it "omits nil attributes" do
104
+ ingredient = described_class.new(name: "salt")
105
+
106
+ expect(ingredient.to_h).to eq({ name: "salt" })
107
+ end
108
+ end
109
+
110
+ describe "#==" do
111
+ it "returns true for ingredients with same attributes" do
112
+ ingredient1 = described_class.new(name: "flour", quantity: 125, unit: "g")
113
+ ingredient2 = described_class.new(name: "flour", quantity: 125, unit: "g")
114
+
115
+ expect(ingredient1).to eq(ingredient2)
116
+ end
117
+
118
+ it "returns false for ingredients with different names" do
119
+ ingredient1 = described_class.new(name: "flour")
120
+ ingredient2 = described_class.new(name: "salt")
121
+
122
+ expect(ingredient1).not_to eq(ingredient2)
123
+ end
124
+
125
+ it "returns false for ingredients with different quantities" do
126
+ ingredient1 = described_class.new(name: "flour", quantity: 125)
127
+ ingredient2 = described_class.new(name: "flour", quantity: 100)
128
+
129
+ expect(ingredient1).not_to eq(ingredient2)
130
+ end
131
+
132
+ it "returns false for non-Ingredient objects" do
133
+ ingredient = described_class.new(name: "flour")
134
+
135
+ expect(ingredient).not_to eq("flour")
136
+ end
137
+ end
138
+
139
+ describe "predicate methods" do
140
+ describe "#has_quantity?" do
141
+ it "returns true when quantity is present" do
142
+ ingredient = described_class.new(name: "flour", quantity: 125)
143
+ expect(ingredient).to have_quantity
144
+ end
145
+
146
+ it "returns false when quantity is nil" do
147
+ ingredient = described_class.new(name: "flour")
148
+ expect(ingredient).not_to have_quantity
149
+ end
150
+ end
151
+
152
+ describe "#has_unit?" do
153
+ it "returns true when unit is present" do
154
+ ingredient = described_class.new(name: "flour", unit: "g")
155
+ expect(ingredient).to have_unit
156
+ end
157
+
158
+ it "returns false when unit is nil" do
159
+ ingredient = described_class.new(name: "flour")
160
+ expect(ingredient).not_to have_unit
161
+ end
162
+ end
163
+
164
+ describe "#has_notes?" do
165
+ it "returns true when notes are present" do
166
+ ingredient = described_class.new(name: "flour", notes: "sifted")
167
+ expect(ingredient).to have_notes
168
+ end
169
+
170
+ it "returns false when notes are nil" do
171
+ ingredient = described_class.new(name: "flour")
172
+ expect(ingredient).not_to have_notes
173
+ end
174
+ end
175
+ end
176
+
177
+ describe "#hash" do
178
+ it "generates same hash for equal ingredients" do
179
+ ingredient1 = described_class.new(name: "flour", quantity: 125)
180
+ ingredient2 = described_class.new(name: "flour", quantity: 125)
181
+
182
+ expect(ingredient1.hash).to eq(ingredient2.hash)
183
+ end
184
+
185
+ it "generates different hash for different ingredients" do
186
+ ingredient1 = described_class.new(name: "flour")
187
+ ingredient2 = described_class.new(name: "salt")
188
+
189
+ expect(ingredient1.hash).not_to eq(ingredient2.hash)
190
+ end
191
+ end
192
+ end