cooklang 0.1.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.
- 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 +289 -75
- data/Gemfile.lock +65 -26
- data/{LICENSE → LICENSE.txt} +6 -6
- data/README.md +106 -12
- data/Rakefile +5 -1
- data/cooklang.gemspec +35 -0
- data/lib/cooklang/builders/recipe_builder.rb +64 -0
- data/lib/cooklang/builders/step_builder.rb +76 -0
- data/lib/cooklang/cookware.rb +43 -0
- data/lib/cooklang/formatter.rb +61 -0
- data/lib/cooklang/formatters/text.rb +18 -0
- data/lib/cooklang/ingredient.rb +60 -0
- data/lib/cooklang/lexer.rb +282 -0
- data/lib/cooklang/metadata.rb +98 -0
- data/lib/cooklang/note.rb +27 -0
- data/lib/cooklang/parser.rb +41 -0
- data/lib/cooklang/parsers/cookware_parser.rb +133 -0
- data/lib/cooklang/parsers/ingredient_parser.rb +179 -0
- data/lib/cooklang/parsers/timer_parser.rb +135 -0
- data/lib/cooklang/processors/element_parser.rb +45 -0
- data/lib/cooklang/processors/metadata_processor.rb +129 -0
- data/lib/cooklang/processors/step_processor.rb +208 -0
- data/lib/cooklang/processors/token_processor.rb +104 -0
- data/lib/cooklang/recipe.rb +72 -0
- data/lib/cooklang/section.rb +33 -0
- data/lib/cooklang/step.rb +99 -0
- data/lib/cooklang/timer.rb +65 -0
- data/lib/cooklang/token_stream.rb +130 -0
- data/lib/cooklang/version.rb +1 -1
- data/lib/cooklang.rb +22 -1
- 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 +171 -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 +141 -24
- data/.ruby-version +0 -1
- data/CHANGELOG.md +0 -5
- data/bin/console +0 -15
- data/bin/setup +0 -8
@@ -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
|