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,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Cooklang::Step do
6
+ describe "#initialize" do
7
+ it "creates step with segments" do
8
+ segments = ["Mix the ", { type: :ingredient, name: "flour" }, " and ", { type: :ingredient, name: "salt" }]
9
+ step = described_class.new(segments: segments)
10
+
11
+ expect(step.segments).to eq(segments)
12
+ expect(step.segments).to be_frozen
13
+ end
14
+ end
15
+
16
+ describe "#to_text" do
17
+ it "converts mixed segments to plain text" do
18
+ segments = [
19
+ "Mix the ",
20
+ { type: :ingredient, name: "flour" },
21
+ " with ",
22
+ { type: :cookware, name: "spoon" },
23
+ " for ",
24
+ { type: :timer, name: "mixing" }
25
+ ]
26
+ step = described_class.new(segments: segments)
27
+
28
+ expect(step.to_text).to eq("Mix the flour with spoon for mixing")
29
+ end
30
+
31
+ it "handles timer without name" do
32
+ segments = ["Cook for ", { type: :timer, duration: 5, unit: "minutes" }]
33
+ step = described_class.new(segments: segments)
34
+
35
+ expect(step.to_text).to eq("Cook for timer")
36
+ end
37
+
38
+ it "handles string segments" do
39
+ segments = ["Just plain text"]
40
+ step = described_class.new(segments: segments)
41
+
42
+ expect(step.to_text).to eq("Just plain text")
43
+ end
44
+
45
+ it "handles unknown segment types" do
46
+ segments = ["Text", { type: :unknown, value: "something" }]
47
+ step = described_class.new(segments: segments)
48
+
49
+ expect(step.to_text).to eq("Textsomething")
50
+ end
51
+
52
+ it "handles segments without value" do
53
+ segments = ["Text", { type: :unknown }]
54
+ step = described_class.new(segments: segments)
55
+
56
+ expect(step.to_text).to eq("Text")
57
+ end
58
+
59
+ it "handles non-hash, non-string segments" do
60
+ segments = ["Text", 123]
61
+ step = described_class.new(segments: segments)
62
+
63
+ expect(step.to_text).to eq("Text123")
64
+ end
65
+ end
66
+
67
+ describe "#to_h" do
68
+ it "returns hash representation" do
69
+ segments = ["Mix ", { type: :ingredient, name: "flour" }]
70
+ step = described_class.new(segments: segments)
71
+
72
+ expect(step.to_h).to eq({ segments: segments })
73
+ end
74
+ end
75
+
76
+ describe "#==" do
77
+ it "returns true for steps with same segments" do
78
+ segments = ["Mix ", { type: :ingredient, name: "flour" }]
79
+ step1 = described_class.new(segments: segments)
80
+ step2 = described_class.new(segments: segments)
81
+
82
+ expect(step1).to eq(step2)
83
+ end
84
+
85
+ it "returns false for steps with different segments" do
86
+ step1 = described_class.new(segments: ["Mix flour"])
87
+ step2 = described_class.new(segments: ["Mix salt"])
88
+
89
+ expect(step1).not_to eq(step2)
90
+ end
91
+
92
+ it "returns false for non-Step objects" do
93
+ step = described_class.new(segments: ["Mix flour"])
94
+
95
+ expect(step).not_to eq("Mix flour")
96
+ end
97
+ end
98
+
99
+ describe "#ingredients_used" do
100
+ it "returns names of ingredients used in step" do
101
+ segments = [
102
+ "Mix ",
103
+ { type: :ingredient, name: "flour" },
104
+ " and ",
105
+ { type: :ingredient, name: "salt" },
106
+ " with ",
107
+ { type: :cookware, name: "spoon" }
108
+ ]
109
+ step = described_class.new(segments: segments)
110
+
111
+ expect(step.ingredients_used).to eq(["flour", "salt"])
112
+ end
113
+
114
+ it "returns empty array when no ingredients" do
115
+ segments = ["Just mix with ", { type: :cookware, name: "spoon" }]
116
+ step = described_class.new(segments: segments)
117
+
118
+ expect(step.ingredients_used).to eq([])
119
+ end
120
+ end
121
+
122
+ describe "#cookware_used" do
123
+ it "returns names of cookware used in step" do
124
+ segments = [
125
+ "Mix with ",
126
+ { type: :cookware, name: "spoon" },
127
+ " in ",
128
+ { type: :cookware, name: "bowl" },
129
+ " using ",
130
+ { type: :ingredient, name: "flour" }
131
+ ]
132
+ step = described_class.new(segments: segments)
133
+
134
+ expect(step.cookware_used).to eq(["spoon", "bowl"])
135
+ end
136
+
137
+ it "returns empty array when no cookware" do
138
+ segments = ["Mix ", { type: :ingredient, name: "flour" }]
139
+ step = described_class.new(segments: segments)
140
+
141
+ expect(step.cookware_used).to eq([])
142
+ end
143
+ end
144
+
145
+ describe "#timers_used" do
146
+ it "returns timer segments used in step" do
147
+ timer1 = { type: :timer, name: "mixing", duration: 2, unit: "minutes" }
148
+ timer2 = { type: :timer, duration: 5, unit: "minutes" }
149
+ segments = [
150
+ "Mix for ",
151
+ timer1,
152
+ " then wait ",
153
+ timer2,
154
+ " using ",
155
+ { type: :ingredient, name: "flour" }
156
+ ]
157
+ step = described_class.new(segments: segments)
158
+
159
+ expect(step.timers_used).to eq([timer1, timer2])
160
+ end
161
+
162
+ it "returns empty array when no timers" do
163
+ segments = ["Mix ", { type: :ingredient, name: "flour" }]
164
+ step = described_class.new(segments: segments)
165
+
166
+ expect(step.timers_used).to eq([])
167
+ end
168
+ end
169
+
170
+ describe "predicate methods" do
171
+ describe "#has_ingredients?" do
172
+ it "returns true when step contains ingredients" do
173
+ segments = ["Mix ", { type: :ingredient, name: "flour" }]
174
+ step = described_class.new(segments: segments)
175
+
176
+ expect(step).to have_ingredients
177
+ end
178
+
179
+ it "returns false when step has no ingredients" do
180
+ segments = ["Just text"]
181
+ step = described_class.new(segments: segments)
182
+
183
+ expect(step).not_to have_ingredients
184
+ end
185
+ end
186
+
187
+ describe "#has_cookware?" do
188
+ it "returns true when step contains cookware" do
189
+ segments = ["Use ", { type: :cookware, name: "spoon" }]
190
+ step = described_class.new(segments: segments)
191
+
192
+ expect(step).to have_cookware
193
+ end
194
+
195
+ it "returns false when step has no cookware" do
196
+ segments = ["Just text"]
197
+ step = described_class.new(segments: segments)
198
+
199
+ expect(step).not_to have_cookware
200
+ end
201
+ end
202
+
203
+ describe "#has_timers?" do
204
+ it "returns true when step contains timers" do
205
+ segments = ["Wait ", { type: :timer, duration: 5, unit: "minutes" }]
206
+ step = described_class.new(segments: segments)
207
+
208
+ expect(step).to have_timers
209
+ end
210
+
211
+ it "returns false when step has no timers" do
212
+ segments = ["Just text"]
213
+ step = described_class.new(segments: segments)
214
+
215
+ expect(step).not_to have_timers
216
+ end
217
+ end
218
+ end
219
+
220
+ describe "#hash" do
221
+ it "generates same hash for equal steps" do
222
+ segments = ["Mix ", { type: :ingredient, name: "flour" }]
223
+ step1 = described_class.new(segments: segments)
224
+ step2 = described_class.new(segments: segments)
225
+
226
+ expect(step1.hash).to eq(step2.hash)
227
+ end
228
+
229
+ it "generates different hash for different steps" do
230
+ step1 = described_class.new(segments: ["Mix flour"])
231
+ step2 = described_class.new(segments: ["Mix salt"])
232
+
233
+ expect(step1.hash).not_to eq(step2.hash)
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe Cooklang::Timer do
6
+ describe "#initialize" do
7
+ it "creates timer with name, duration and unit" do
8
+ timer = described_class.new(name: "baking", duration: 30, unit: "minutes")
9
+
10
+ expect(timer.name).to eq("baking")
11
+ expect(timer.duration).to eq(30)
12
+ expect(timer.unit).to eq("minutes")
13
+ end
14
+
15
+ it "creates timer without name" do
16
+ timer = described_class.new(duration: 15, unit: "seconds")
17
+
18
+ expect(timer.name).to be_nil
19
+ expect(timer.duration).to eq(15)
20
+ expect(timer.unit).to eq("seconds")
21
+ end
22
+
23
+ it "converts name to string and freezes it" do
24
+ timer = described_class.new(name: :cooking, duration: 5, unit: "minutes")
25
+
26
+ expect(timer.name).to eq("cooking")
27
+ expect(timer.name).to be_frozen
28
+ end
29
+
30
+ it "converts unit to string and freezes it" do
31
+ timer = described_class.new(duration: 5, unit: :minutes)
32
+
33
+ expect(timer.unit).to eq("minutes")
34
+ expect(timer.unit).to be_frozen
35
+ end
36
+ end
37
+
38
+ describe "#to_s" do
39
+ it "returns duration and unit when no name" do
40
+ timer = described_class.new(duration: 5, unit: "minutes")
41
+ expect(timer.to_s).to eq("5 minutes")
42
+ end
43
+
44
+ it "includes name when present" do
45
+ timer = described_class.new(name: "baking", duration: 30, unit: "minutes")
46
+ expect(timer.to_s).to eq("baking: 30 minutes")
47
+ end
48
+ end
49
+
50
+ describe "#to_h" do
51
+ it "returns hash with all present attributes" do
52
+ timer = described_class.new(name: "baking", duration: 30, unit: "minutes")
53
+
54
+ expected = {
55
+ name: "baking",
56
+ duration: 30,
57
+ unit: "minutes"
58
+ }
59
+
60
+ expect(timer.to_h).to eq(expected)
61
+ end
62
+
63
+ it "omits nil attributes" do
64
+ timer = described_class.new(duration: 5, unit: "minutes")
65
+
66
+ expected = {
67
+ duration: 5,
68
+ unit: "minutes"
69
+ }
70
+
71
+ expect(timer.to_h).to eq(expected)
72
+ end
73
+ end
74
+
75
+ describe "#==" do
76
+ it "returns true for timers with same attributes" do
77
+ timer1 = described_class.new(name: "cooking", duration: 5, unit: "minutes")
78
+ timer2 = described_class.new(name: "cooking", duration: 5, unit: "minutes")
79
+
80
+ expect(timer1).to eq(timer2)
81
+ end
82
+
83
+ it "returns false for timers with different names" do
84
+ timer1 = described_class.new(name: "cooking", duration: 5, unit: "minutes")
85
+ timer2 = described_class.new(name: "baking", duration: 5, unit: "minutes")
86
+
87
+ expect(timer1).not_to eq(timer2)
88
+ end
89
+
90
+ it "returns false for timers with different durations" do
91
+ timer1 = described_class.new(duration: 5, unit: "minutes")
92
+ timer2 = described_class.new(duration: 10, unit: "minutes")
93
+
94
+ expect(timer1).not_to eq(timer2)
95
+ end
96
+
97
+ it "returns false for non-Timer objects" do
98
+ timer = described_class.new(duration: 5, unit: "minutes")
99
+
100
+ expect(timer).not_to eq("5 minutes")
101
+ end
102
+ end
103
+
104
+ describe "#total_seconds" do
105
+ it "converts seconds to seconds" do
106
+ timer = described_class.new(duration: 30, unit: "seconds")
107
+ expect(timer.total_seconds).to eq(30)
108
+ end
109
+
110
+ it "converts minutes to seconds" do
111
+ timer = described_class.new(duration: 5, unit: "minutes")
112
+ expect(timer.total_seconds).to eq(300)
113
+ end
114
+
115
+ it "converts hours to seconds" do
116
+ timer = described_class.new(duration: 2, unit: "hours")
117
+ expect(timer.total_seconds).to eq(7200)
118
+ end
119
+
120
+ it "converts days to seconds" do
121
+ timer = described_class.new(duration: 1, unit: "days")
122
+ expect(timer.total_seconds).to eq(86_400)
123
+ end
124
+
125
+ it "handles various unit formats" do
126
+ expect(described_class.new(duration: 30, unit: "sec").total_seconds).to eq(30)
127
+ expect(described_class.new(duration: 30, unit: "s").total_seconds).to eq(30)
128
+ expect(described_class.new(duration: 5, unit: "min").total_seconds).to eq(300)
129
+ expect(described_class.new(duration: 5, unit: "m").total_seconds).to eq(300)
130
+ expect(described_class.new(duration: 2, unit: "hr").total_seconds).to eq(7200)
131
+ expect(described_class.new(duration: 2, unit: "h").total_seconds).to eq(7200)
132
+ expect(described_class.new(duration: 1, unit: "d").total_seconds).to eq(86_400)
133
+ end
134
+
135
+ it "handles case insensitive units" do
136
+ timer = described_class.new(duration: 5, unit: "MINUTES")
137
+ expect(timer.total_seconds).to eq(300)
138
+ end
139
+
140
+ it "returns duration for unknown units" do
141
+ timer = described_class.new(duration: 10, unit: "unknown")
142
+ expect(timer.total_seconds).to eq(10)
143
+ end
144
+ end
145
+
146
+ describe "#has_name?" do
147
+ it "returns true when name is present" do
148
+ timer = described_class.new(name: "cooking", duration: 5, unit: "minutes")
149
+ expect(timer).to have_name
150
+ end
151
+
152
+ it "returns false when name is nil" do
153
+ timer = described_class.new(duration: 5, unit: "minutes")
154
+ expect(timer).not_to have_name
155
+ end
156
+ end
157
+
158
+ describe "#hash" do
159
+ it "generates same hash for equal timers" do
160
+ timer1 = described_class.new(name: "cooking", duration: 5, unit: "minutes")
161
+ timer2 = described_class.new(name: "cooking", duration: 5, unit: "minutes")
162
+
163
+ expect(timer1.hash).to eq(timer2.hash)
164
+ end
165
+
166
+ it "generates different hash for different timers" do
167
+ timer1 = described_class.new(duration: 5, unit: "minutes")
168
+ timer2 = described_class.new(duration: 10, unit: "minutes")
169
+
170
+ expect(timer1.hash).not_to eq(timer2.hash)
171
+ end
172
+ end
173
+ end