hayadentaku 3.5.7

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 (132) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/rspec.yml +26 -0
  3. data/.github/workflows/rubocop.yml +14 -0
  4. data/.gitignore +14 -0
  5. data/.pryrc +2 -0
  6. data/.rubocop.yml +114 -0
  7. data/.travis.yml +10 -0
  8. data/CHANGELOG.md +328 -0
  9. data/Gemfile +4 -0
  10. data/LICENSE +21 -0
  11. data/README.md +352 -0
  12. data/Rakefile +31 -0
  13. data/hayadentaku.gemspec +35 -0
  14. data/lib/dentaku/ast/access.rb +44 -0
  15. data/lib/dentaku/ast/arithmetic.rb +292 -0
  16. data/lib/dentaku/ast/array.rb +38 -0
  17. data/lib/dentaku/ast/bitwise.rb +42 -0
  18. data/lib/dentaku/ast/case/case_conditional.rb +38 -0
  19. data/lib/dentaku/ast/case/case_else.rb +35 -0
  20. data/lib/dentaku/ast/case/case_switch_variable.rb +35 -0
  21. data/lib/dentaku/ast/case/case_then.rb +35 -0
  22. data/lib/dentaku/ast/case/case_when.rb +39 -0
  23. data/lib/dentaku/ast/case.rb +93 -0
  24. data/lib/dentaku/ast/combinators.rb +50 -0
  25. data/lib/dentaku/ast/comparators.rb +88 -0
  26. data/lib/dentaku/ast/datetime.rb +8 -0
  27. data/lib/dentaku/ast/function.rb +56 -0
  28. data/lib/dentaku/ast/function_registry.rb +107 -0
  29. data/lib/dentaku/ast/functions/abs.rb +5 -0
  30. data/lib/dentaku/ast/functions/all.rb +19 -0
  31. data/lib/dentaku/ast/functions/and.rb +25 -0
  32. data/lib/dentaku/ast/functions/any.rb +19 -0
  33. data/lib/dentaku/ast/functions/avg.rb +13 -0
  34. data/lib/dentaku/ast/functions/count.rb +26 -0
  35. data/lib/dentaku/ast/functions/duration.rb +51 -0
  36. data/lib/dentaku/ast/functions/enum.rb +54 -0
  37. data/lib/dentaku/ast/functions/filter.rb +21 -0
  38. data/lib/dentaku/ast/functions/if.rb +47 -0
  39. data/lib/dentaku/ast/functions/intercept.rb +33 -0
  40. data/lib/dentaku/ast/functions/map.rb +19 -0
  41. data/lib/dentaku/ast/functions/max.rb +5 -0
  42. data/lib/dentaku/ast/functions/min.rb +5 -0
  43. data/lib/dentaku/ast/functions/mul.rb +12 -0
  44. data/lib/dentaku/ast/functions/not.rb +5 -0
  45. data/lib/dentaku/ast/functions/or.rb +25 -0
  46. data/lib/dentaku/ast/functions/pluck.rb +34 -0
  47. data/lib/dentaku/ast/functions/reduce.rb +60 -0
  48. data/lib/dentaku/ast/functions/round.rb +5 -0
  49. data/lib/dentaku/ast/functions/rounddown.rb +8 -0
  50. data/lib/dentaku/ast/functions/roundup.rb +8 -0
  51. data/lib/dentaku/ast/functions/ruby_math.rb +57 -0
  52. data/lib/dentaku/ast/functions/string_functions.rb +212 -0
  53. data/lib/dentaku/ast/functions/sum.rb +12 -0
  54. data/lib/dentaku/ast/functions/switch.rb +8 -0
  55. data/lib/dentaku/ast/functions/xor.rb +44 -0
  56. data/lib/dentaku/ast/grouping.rb +23 -0
  57. data/lib/dentaku/ast/identifier.rb +52 -0
  58. data/lib/dentaku/ast/literal.rb +30 -0
  59. data/lib/dentaku/ast/logical.rb +8 -0
  60. data/lib/dentaku/ast/negation.rb +54 -0
  61. data/lib/dentaku/ast/nil.rb +13 -0
  62. data/lib/dentaku/ast/node.rb +29 -0
  63. data/lib/dentaku/ast/numeric.rb +8 -0
  64. data/lib/dentaku/ast/operation.rb +44 -0
  65. data/lib/dentaku/ast/string.rb +15 -0
  66. data/lib/dentaku/ast.rb +42 -0
  67. data/lib/dentaku/bulk_expression_solver.rb +158 -0
  68. data/lib/dentaku/calculator.rb +192 -0
  69. data/lib/dentaku/date_arithmetic.rb +60 -0
  70. data/lib/dentaku/dependency_resolver.rb +29 -0
  71. data/lib/dentaku/exceptions.rb +116 -0
  72. data/lib/dentaku/flat_hash.rb +161 -0
  73. data/lib/dentaku/parser.rb +318 -0
  74. data/lib/dentaku/print_visitor.rb +112 -0
  75. data/lib/dentaku/string_casing.rb +7 -0
  76. data/lib/dentaku/token.rb +48 -0
  77. data/lib/dentaku/token_matcher.rb +138 -0
  78. data/lib/dentaku/token_matchers.rb +29 -0
  79. data/lib/dentaku/token_scanner.rb +240 -0
  80. data/lib/dentaku/tokenizer.rb +127 -0
  81. data/lib/dentaku/version.rb +3 -0
  82. data/lib/dentaku/visitor/infix.rb +86 -0
  83. data/lib/dentaku.rb +69 -0
  84. data/spec/ast/abs_spec.rb +26 -0
  85. data/spec/ast/addition_spec.rb +67 -0
  86. data/spec/ast/all_spec.rb +38 -0
  87. data/spec/ast/and_function_spec.rb +35 -0
  88. data/spec/ast/and_spec.rb +32 -0
  89. data/spec/ast/any_spec.rb +36 -0
  90. data/spec/ast/arithmetic_spec.rb +147 -0
  91. data/spec/ast/avg_spec.rb +42 -0
  92. data/spec/ast/case_spec.rb +84 -0
  93. data/spec/ast/comparator_spec.rb +87 -0
  94. data/spec/ast/count_spec.rb +40 -0
  95. data/spec/ast/division_spec.rb +64 -0
  96. data/spec/ast/filter_spec.rb +25 -0
  97. data/spec/ast/function_spec.rb +69 -0
  98. data/spec/ast/intercept_spec.rb +30 -0
  99. data/spec/ast/map_spec.rb +40 -0
  100. data/spec/ast/max_spec.rb +33 -0
  101. data/spec/ast/min_spec.rb +33 -0
  102. data/spec/ast/mul_spec.rb +43 -0
  103. data/spec/ast/negation_spec.rb +48 -0
  104. data/spec/ast/node_spec.rb +43 -0
  105. data/spec/ast/numeric_spec.rb +16 -0
  106. data/spec/ast/or_spec.rb +35 -0
  107. data/spec/ast/pluck_spec.rb +49 -0
  108. data/spec/ast/reduce_spec.rb +22 -0
  109. data/spec/ast/round_spec.rb +35 -0
  110. data/spec/ast/rounddown_spec.rb +35 -0
  111. data/spec/ast/roundup_spec.rb +35 -0
  112. data/spec/ast/string_functions_spec.rb +217 -0
  113. data/spec/ast/sum_spec.rb +43 -0
  114. data/spec/ast/switch_spec.rb +30 -0
  115. data/spec/ast/xor_spec.rb +35 -0
  116. data/spec/benchmark.rb +70 -0
  117. data/spec/bulk_expression_solver_spec.rb +241 -0
  118. data/spec/calculator_spec.rb +1003 -0
  119. data/spec/dentaku_spec.rb +52 -0
  120. data/spec/dependency_resolver_spec.rb +18 -0
  121. data/spec/exceptions_spec.rb +9 -0
  122. data/spec/external_function_spec.rb +177 -0
  123. data/spec/parser_spec.rb +183 -0
  124. data/spec/print_visitor_spec.rb +77 -0
  125. data/spec/spec_helper.rb +69 -0
  126. data/spec/token_matcher_spec.rb +134 -0
  127. data/spec/token_scanner_spec.rb +49 -0
  128. data/spec/token_spec.rb +16 -0
  129. data/spec/tokenizer_spec.rb +375 -0
  130. data/spec/visitor/infix_spec.rb +52 -0
  131. data/spec/visitor_spec.rb +139 -0
  132. metadata +353 -0
@@ -0,0 +1,1003 @@
1
+ require 'spec_helper'
2
+ require 'dentaku'
3
+ describe Dentaku::Calculator do
4
+ let(:calculator) { described_class.new }
5
+ let(:with_case_sensitivity) { described_class.new(case_sensitive: true) }
6
+ let(:with_memory) { described_class.new.store(apples: 3) }
7
+ let(:with_aliases) { described_class.new(aliases: { round: ['rrround'] }) }
8
+ let(:without_nested_data) { described_class.new(nested_data_support: false) }
9
+
10
+ it 'evaluates an expression' do
11
+ expect(calculator.evaluate('7+3')).to eq(10)
12
+ expect(calculator.evaluate('2 -1')).to eq(1)
13
+ expect(calculator.evaluate('-1 + 2')).to eq(1)
14
+ expect(calculator.evaluate('1 - 2')).to eq(-1)
15
+ expect(calculator.evaluate('1 - - 2')).to eq(3)
16
+ expect(calculator.evaluate('-1 - - 2')).to eq(1)
17
+ expect(calculator.evaluate('1 - - - 2')).to eq(-1)
18
+ expect(calculator.evaluate('(-1 + 2)')).to eq(1)
19
+ expect(calculator.evaluate('-(1 + 2)')).to eq(-3)
20
+ expect(calculator.evaluate('2 ^ - 1')).to eq(0.5)
21
+ expect(calculator.evaluate('2 ^ -(3 - 2)')).to eq(0.5)
22
+ expect(calculator.evaluate('(2 + 3) - 1')).to eq(4)
23
+ expect(calculator.evaluate('(-2 + 3) - 1')).to eq(0)
24
+ expect(calculator.evaluate('(-2 - 3) - 1')).to eq(-6)
25
+ expect(calculator.evaluate('1353+91-1-3322-22')).to eq(-1901)
26
+ expect(calculator.evaluate('1 + -(2 ^ 2)')).to eq(-3)
27
+ expect(calculator.evaluate('3 + -num', num: 2)).to eq(1)
28
+ expect(calculator.evaluate('-num + 3', num: 2)).to eq(1)
29
+ expect(calculator.evaluate('10 ^ 2')).to eq(100)
30
+ expect(calculator.evaluate('0 * 10 ^ -5')).to eq(0)
31
+ expect(calculator.evaluate('3 + 0 * -3')).to eq(3)
32
+ expect(calculator.evaluate('3 + 0 / -3')).to eq(3)
33
+ expect(calculator.evaluate('(((695759/735000)^(1/(1981-1991)))-1)*1000').round(4)).to eq(5.5018)
34
+ expect(calculator.evaluate('0.253/0.253')).to eq(1)
35
+ expect(calculator.evaluate('0.253/d', d: 0.253)).to eq(1)
36
+ expect(calculator.evaluate('10 + x', x: 'abc')).to be_nil
37
+ expect(calculator.evaluate('x * y', x: '.123', y: '100')).to eq(12.3)
38
+ expect(calculator.evaluate('a/b', a: '10', b: '2')).to eq(5)
39
+ expect(calculator.evaluate('t + 1*24*60*60', t: Time.local(2017, 1, 1))).to eq(Time.local(2017, 1, 2))
40
+ expect(calculator.evaluate("2 | 3 * 9")).to eq (27)
41
+ expect(calculator.evaluate("2 & 3 * 9")).to eq (2)
42
+ expect(calculator.evaluate('1 << 3')).to eq (8)
43
+ expect(calculator.evaluate('0xFF >> 6')).to eq (3)
44
+ end
45
+
46
+ it "differentiates between percentage and modulo operators" do
47
+ expect(calculator.evaluate('15 % 8')).to eq(7)
48
+ expect(calculator.evaluate('15 % (4 * 2)')).to eq(7)
49
+ expect(calculator.evaluate("5%")).to eq (0.05)
50
+ expect(calculator.evaluate("400/60%").round(2)).to eq (666.67)
51
+ expect(calculator.evaluate("(400/60%)*1").round(2)).to eq (666.67)
52
+ expect(calculator.evaluate("60% * 1").round(2)).to eq (0.60)
53
+ expect(calculator.evaluate("50% + 50%")).to eq (1.0)
54
+ end
55
+
56
+ describe 'evaluate' do
57
+ it 'returns nil when formula has error' do
58
+ expect(calculator.evaluate('1 + + 1')).to be_nil
59
+ end
60
+
61
+ it 'suppresses unbound variable errors' do
62
+ expect(calculator.evaluate('AND(a,b)')).to be_nil
63
+ expect(calculator.evaluate('IF(a, 1, 0)')).to be_nil
64
+ expect(calculator.evaluate('MAX(a,b)')).to be_nil
65
+ expect(calculator.evaluate('MIN(a,b)')).to be_nil
66
+ expect(calculator.evaluate('NOT(a)')).to be_nil
67
+ expect(calculator.evaluate('OR(a,b)')).to be_nil
68
+ expect(calculator.evaluate('ROUND(a)')).to be_nil
69
+ expect(calculator.evaluate('ROUNDDOWN(a)')).to be_nil
70
+ expect(calculator.evaluate('ROUNDUP(a)')).to be_nil
71
+ expect(calculator.evaluate('SUM(a,b)')).to be_nil
72
+ end
73
+
74
+ it 'suppresses numeric coercion errors' do
75
+ expect(calculator.evaluate('MAX(a,b)', a: nil, b: nil)).to be_nil
76
+ expect(calculator.evaluate('MIN(a,b)', a: nil, b: nil)).to be_nil
77
+ expect(calculator.evaluate('ROUND(a)', a: nil)).to be_nil
78
+ expect(calculator.evaluate('ROUNDDOWN(a)', a: nil)).to be_nil
79
+ expect(calculator.evaluate('ROUNDUP(a)', a: nil)).to be_nil
80
+ expect(calculator.evaluate('SUM(a,b)', a: nil, b: nil)).to be_nil
81
+ expect(calculator.evaluate('1.0 & "bar"')).to be_nil
82
+ end
83
+
84
+ it 'treats explicit nil as logical false' do
85
+ expect(calculator.evaluate('AND(a,b)', a: nil, b: nil)).to be_falsy
86
+ expect(calculator.evaluate('IF(a,1,0)', a: nil, b: nil)).to eq(0)
87
+ expect(calculator.evaluate('NOT(a)', a: nil, b: nil)).to be_truthy
88
+ expect(calculator.evaluate('OR(a,b)', a: nil, b: nil)).to be_falsy
89
+ end
90
+
91
+ it 'supports lazy evaluation of variables' do
92
+ expect(calculator.evaluate('x + 1', x: -> { 1 })).to eq(2)
93
+ expect { calculator.evaluate('2', x: -> { raise 'boom' }) }.not_to raise_error
94
+ end
95
+ end
96
+
97
+ describe 'ast' do
98
+ it 'raises parsing errors' do
99
+ expect { calculator.ast('()') }.to raise_error(Dentaku::ParseError)
100
+ expect { calculator.ast('(}') }.to raise_error(Dentaku::TokenizerError)
101
+ end
102
+ end
103
+
104
+ describe 'evaluate!' do
105
+ it 'raises exception when formula has error' do
106
+ expect { calculator.evaluate!('1 + + 1') }.to raise_error(Dentaku::ParseError)
107
+ expect { calculator.evaluate!('(1 > 5) OR LEFT("abc", 1)') }.to raise_error(Dentaku::ParseError)
108
+ end
109
+
110
+ it 'raises unbound variable errors' do
111
+ expect { calculator.evaluate!('AND(a,b)') }.to raise_error(Dentaku::UnboundVariableError)
112
+ expect { calculator.evaluate!('IF(a, 1, 0)') }.to raise_error(Dentaku::UnboundVariableError)
113
+ expect { calculator.evaluate!('MAX(a,b)') }.to raise_error(Dentaku::UnboundVariableError)
114
+ expect { calculator.evaluate!('MIN(a,b)') }.to raise_error(Dentaku::UnboundVariableError)
115
+ expect { calculator.evaluate!('NOT(a)') }.to raise_error(Dentaku::UnboundVariableError)
116
+ expect { calculator.evaluate!('OR(a,b)') }.to raise_error(Dentaku::UnboundVariableError)
117
+ expect { calculator.evaluate!('ROUND(a)') }.to raise_error(Dentaku::UnboundVariableError)
118
+ expect { calculator.evaluate!('ROUNDDOWN(a)') }.to raise_error(Dentaku::UnboundVariableError)
119
+ expect { calculator.evaluate!('ROUNDUP(a)') }.to raise_error(Dentaku::UnboundVariableError)
120
+ expect { calculator.evaluate!('SUM(a,b)') }.to raise_error(Dentaku::UnboundVariableError)
121
+ end
122
+
123
+ it 'raises numeric coersion errors' do
124
+ expect { calculator.evaluate!('MAX(a,b)', a: nil, b: nil) }.to raise_error(Dentaku::ArgumentError)
125
+ expect { calculator.evaluate!('MIN(a,b)', a: nil, b: nil) }.to raise_error(Dentaku::ArgumentError)
126
+ expect { calculator.evaluate!('ROUND(a)', a: nil) }.to raise_error(Dentaku::ArgumentError)
127
+ expect { calculator.evaluate!('ROUNDDOWN(a)', a: nil) }.to raise_error(Dentaku::ArgumentError)
128
+ expect { calculator.evaluate!('ROUNDUP(a)', a: nil) }.to raise_error(Dentaku::ArgumentError)
129
+ expect { calculator.evaluate!('SUM(a,b)', a: nil, b: nil) }.to raise_error(Dentaku::ArgumentError)
130
+ expect { calculator.evaluate!('"foo" & "bar"') }.to raise_error(Dentaku::ArgumentError)
131
+ expect { calculator.evaluate!('1.0 & "bar"') }.to raise_error(Dentaku::ArgumentError)
132
+ expect { calculator.evaluate!('1 & "bar"') }.to raise_error(Dentaku::ArgumentError)
133
+ expect { calculator.evaluate!('data < 1', data: { a: 5 }) }.to raise_error(Dentaku::ArgumentError)
134
+ end
135
+
136
+ it 'raises argument error if a function is called with incorrect arity' do
137
+ expect { calculator.evaluate!('IF(a,b)', a: 1, b: 1) }.to raise_error(Dentaku::ParseError)
138
+ end
139
+ end
140
+
141
+ it 'supports unicode characters in identifiers' do
142
+ expect(calculator.evaluate("ρ * 2", ρ: 2)).to eq (4)
143
+ end
144
+
145
+ describe 'memory' do
146
+ it { expect(calculator).to be_empty }
147
+ it { expect(with_memory).not_to be_empty }
148
+ it { expect(with_memory.clear).to be_empty }
149
+
150
+ it 'discards local values' do
151
+ expect(calculator.evaluate('pears * 2', pears: 5)).to eq(10)
152
+ expect(calculator).to be_empty
153
+ end
154
+
155
+ it 'can store the value `false`' do
156
+ calculator.store('i_am_false', false)
157
+ expect(calculator.evaluate!('i_am_false')).to eq(false)
158
+ end
159
+
160
+ it 'can store multiple values' do
161
+ calculator.store(first: 1, second: 2)
162
+ expect(calculator.evaluate!('first')).to eq(1)
163
+ expect(calculator.evaluate!('second')).to eq(2)
164
+ end
165
+
166
+ it 'stores formulas' do
167
+ calculator.store_formula('area', 'length * width')
168
+ expect(calculator.evaluate!('area', length: 5, width: 5)).to eq(25)
169
+ end
170
+
171
+ it 'stores dates' do
172
+ calculator.store("d1", Date.parse("2024/01/02"))
173
+ calculator.store("d2", Date.parse("2024/01/06"))
174
+ expect(calculator.solve(diff: "d1 - d2")).to eq(diff: -4)
175
+ end
176
+
177
+ it 'stores nested hashes' do
178
+ calculator.store(a: {basket: {of: 'apples'}}, b: 2)
179
+ expect(calculator.evaluate!('a.basket.of')).to eq('apples')
180
+ expect(calculator.evaluate!('a.basket')).to eq(of: 'apples')
181
+ expect(calculator.evaluate!('b')).to eq(2)
182
+ end
183
+
184
+ it 'stores nested hashes with quotes' do
185
+ calculator.store(a: {basket: {of: 'apples'}}, b: 2)
186
+ expect(calculator.evaluate!('`a.basket.of`')).to eq('apples')
187
+ expect(calculator.evaluate!('`a.basket`')).to eq(of: 'apples')
188
+ expect(calculator.evaluate!('`b`')).to eq(2)
189
+ end
190
+
191
+ it 'stores arrays' do
192
+ calculator.store(a: [1, 2, 3])
193
+ expect(calculator.evaluate!('a[0]')).to eq(1)
194
+ expect(calculator.evaluate!('a[x]', x: 1)).to eq(2)
195
+ expect(calculator.evaluate!('a[x+1]', x: 1)).to eq(3)
196
+ end
197
+
198
+ it 'evaluates arrays' do
199
+ expect(calculator.evaluate([1, 2, 3])).to eq([1, 2, 3])
200
+ expect(calculator.evaluate!('{1,2,3}')).to eq([1, 2, 3])
201
+ end
202
+ end
203
+
204
+ describe 'dependencies' do
205
+ it 'respects quoted identifiers in dependencies' do
206
+ expect(calculator.dependencies("`bob the builder` + `dole the digger` / 3")).to eq(['bob the builder', 'dole the digger'])
207
+ end
208
+
209
+ it "finds dependencies in a generic statement" do
210
+ expect(calculator.dependencies("bob + dole / 3")).to eq(['bob', 'dole'])
211
+ end
212
+
213
+ it "ignores dependencies passed in context" do
214
+ expect(calculator.dependencies("a + b", a: 1)).to eq(['b'])
215
+ end
216
+
217
+ it "ignores dependencies passed in context for quoted identifiers" do
218
+ expect(calculator.dependencies("`a-c` + b", "a-c": 1)).to eq(['b'])
219
+ end
220
+
221
+ it "finds dependencies in formula arguments" do
222
+ allow(Dentaku).to receive(:cache_ast?) { true }
223
+
224
+ expect(calculator.dependencies("CONCAT(bob, dole)")).to eq(['bob', 'dole'])
225
+ end
226
+
227
+ it "doesn't consider variables in memory as dependencies" do
228
+ expect(with_memory.dependencies("apples + oranges")).to eq(['oranges'])
229
+ end
230
+
231
+ it "finds no dependencies in array literals" do
232
+ expect(calculator.dependencies([1, 2, 3])).to eq([])
233
+ end
234
+
235
+ it "finds dependencies in item expressions" do
236
+ expect(calculator.dependencies('MAP(vals, val, val + step)')).to eq(['vals', 'step'])
237
+ expect(calculator.dependencies('ALL(people, person, person.age < adult)')).to eq(['people', 'adult'])
238
+ end
239
+
240
+ it "raises an error when trying to find dependencies with invalid syntax" do
241
+ expect { calculator.dependencies('bob + / 3') }.to raise_error(Dentaku::ParseError)
242
+ expect { calculator.dependencies('123 * TRUE') }.to raise_error(Dentaku::ParseError)
243
+ expect { calculator.dependencies('4 + "asdf"') }.to raise_error(Dentaku::ParseError)
244
+ end
245
+ end
246
+
247
+ describe 'solve!' do
248
+ it "evaluates properly with variables, even if some in memory" do
249
+ expect(with_memory.solve!(
250
+ "monthly fruit budget": "weekly_fruit_budget * 4",
251
+ weekly_fruit_budget: "weekly_apple_budget + pear * 4",
252
+ weekly_apple_budget: "apples * 7",
253
+ pear: "1"
254
+ )).to eq(pear: 1, weekly_apple_budget: 21, weekly_fruit_budget: 25, "monthly fruit budget": 100)
255
+ end
256
+
257
+ it "prefers variables over values in memory if they have no dependencies" do
258
+ expect(with_memory.solve!(
259
+ weekly_fruit_budget: "weekly_apple_budget + pear * 4",
260
+ weekly_apple_budget: "apples * 7",
261
+ pear: "1",
262
+ apples: "4"
263
+ )).to eq(apples: 4, pear: 1, weekly_apple_budget: 28, weekly_fruit_budget: 32)
264
+ end
265
+
266
+ it "preserves hash keys" do
267
+ expect(calculator.solve!(
268
+ 'meaning_of_life' => 'age + kids',
269
+ 'age' => 40,
270
+ 'kids' => 2
271
+ )).to eq('age' => 40, 'kids' => 2, 'meaning_of_life' => 42)
272
+ end
273
+
274
+ it "lets you know about a cycle if one occurs" do
275
+ expect do
276
+ calculator.solve!(health: "happiness", happiness: "health")
277
+ end.to raise_error(TSort::Cyclic)
278
+ end
279
+
280
+ it 'is case-insensitive' do
281
+ result = with_memory.solve!(total_fruit: "Apples + pears", pears: 10)
282
+ expect(result[:total_fruit]).to eq(13)
283
+ end
284
+
285
+ it "lets you know if a variable is unbound" do
286
+ expect {
287
+ calculator.solve!(more_apples: "apples + 1")
288
+ }.to raise_error(Dentaku::UnboundVariableError)
289
+ end
290
+
291
+ it 'can reference stored formulas' do
292
+ calculator.store_formula("base_area", "length * width")
293
+ calculator.store_formula("volume", "base_area * height")
294
+
295
+ result = calculator.solve!(
296
+ weight: "volume * 5.432",
297
+ height: "3",
298
+ length: "2",
299
+ width: "length * 2",
300
+ )
301
+
302
+ expect(result[:weight]).to eq(130.368)
303
+ end
304
+
305
+ it 'raises an exception if there are cyclic dependencies' do
306
+ expect {
307
+ calculator.solve!(
308
+ make_money: "have_money",
309
+ have_money: "make_money"
310
+ )
311
+ }.to raise_error(TSort::Cyclic)
312
+ end
313
+ end
314
+
315
+ describe 'solve' do
316
+ it "returns :undefined when variables are unbound" do
317
+ expressions = {more_apples: "apples + 1", compare_apples: "apples > 1"}
318
+ expect(calculator.solve(expressions)).to eq(more_apples: :undefined, compare_apples: :undefined)
319
+ end
320
+
321
+ it "returns :undefined when variables are nil" do
322
+ expressions = {more_apples: "apples + 1", compare_apples: "apples > 1"}
323
+ expect(calculator.store(apples: nil).solve(expressions)).to eq(more_apples: :undefined, compare_apples: :undefined)
324
+ end
325
+
326
+ it "allows passing in a custom value to an error handler" do
327
+ expressions = {more_apples: "apples + 1"}
328
+ expect(calculator.solve(expressions) { :foo })
329
+ .to eq(more_apples: :foo)
330
+ end
331
+
332
+ it "solves remainder of expressions with unbound variable" do
333
+ calculator.store(peaches: 1, oranges: 1)
334
+ expressions = { more_apples: "apples + 1", more_peaches: "peaches + 1" }
335
+ result = calculator.solve(expressions)
336
+ expect(calculator.memory).to eq("peaches" => 1, "oranges" => 1)
337
+ expect(result).to eq(
338
+ more_apples: :undefined,
339
+ more_peaches: 2
340
+ )
341
+ end
342
+
343
+ it "solves remainder of expressions when one cannot be evaluated" do
344
+ result = calculator.solve(
345
+ conditional: "IF(d != 0, ratio, 0)",
346
+ ratio: "10/d",
347
+ d: 0,
348
+ )
349
+
350
+ expect(result).to eq(
351
+ conditional: 0,
352
+ ratio: :undefined,
353
+ d: 0,
354
+ )
355
+ end
356
+
357
+ it 'returns undefined if there are cyclic dependencies' do
358
+ expect {
359
+ result = calculator.solve(
360
+ make_money: "have_money",
361
+ have_money: "make_money"
362
+ )
363
+ expect(result).to eq(
364
+ make_money: :undefined,
365
+ have_money: :undefined
366
+ )
367
+ }.not_to raise_error
368
+ end
369
+
370
+ it 'allows to compare "-" or "-."' do
371
+ expect { calculator.solve("IF('-' = '-', 0, 1)") }.not_to raise_error
372
+ expect { calculator.solve("IF('-.'= '-.', 0, 1)") }.not_to raise_error
373
+ end
374
+
375
+ it "integrates with custom functions" do
376
+ calculator.add_function(:custom, :integer, -> { 1 })
377
+
378
+ result = calculator.solve(
379
+ a: "1",
380
+ b: "CUSTOM() - a"
381
+ )
382
+
383
+ expect(result).to eq(
384
+ a: 1,
385
+ b: 0
386
+ )
387
+ end
388
+ end
389
+
390
+ it 'evaluates a statement with no variables' do
391
+ expect(calculator.evaluate('5+3')).to eq(8)
392
+ expect(calculator.evaluate('(1+1+1)/3*100')).to eq(100)
393
+ end
394
+
395
+ it 'evaluates negation' do
396
+ expect(calculator.evaluate('-negative', negative: -1)).to eq(1)
397
+ expect(calculator.evaluate('-negative', negative: '-1')).to eq(1)
398
+ expect(calculator.evaluate('-negative - 1', negative: '-1')).to eq(0)
399
+ expect(calculator.evaluate('-negative - 1', negative: '1')).to eq(-2)
400
+ expect(calculator.evaluate('-(negative) - 1', negative: '1')).to eq(-2)
401
+ end
402
+
403
+ it 'fails to evaluate unbound statements' do
404
+ unbound = 'foo * 1.5'
405
+ expect { calculator.evaluate!(unbound) }.to raise_error(Dentaku::UnboundVariableError)
406
+ expect { calculator.evaluate!(unbound) }.to raise_error do |error|
407
+ expect(error.unbound_variables).to eq(['foo'])
408
+ end
409
+ expect { calculator.evaluate!('a + b') }.to raise_error do |error|
410
+ expect(error.unbound_variables).to eq(['a', 'b'])
411
+ end
412
+ expect(calculator.evaluate(unbound)).to be_nil
413
+ end
414
+
415
+ it 'accepts a block for custom handling of unbound variables' do
416
+ unbound = 'foo * 1.5'
417
+ expect(calculator.evaluate(unbound) { :bar }).to eq(:bar)
418
+ expect(calculator.evaluate(unbound) { |e| e }).to eq(unbound)
419
+ end
420
+
421
+ it 'fails to evaluate incomplete statements' do
422
+ ['true AND', 'a a ^&'].each do |statement|
423
+ expect {
424
+ calculator.evaluate!(statement)
425
+ }.to raise_error(Dentaku::ParseError)
426
+ end
427
+ end
428
+
429
+ it 'evaluates unbound statements given a binding in memory' do
430
+ expect(calculator.evaluate('foo * 1.5', foo: 2)).to eq(3)
431
+ expect(calculator.bind(monkeys: 3).evaluate('monkeys < 7')).to be_truthy
432
+ expect(calculator.evaluate('monkeys / 1.5')).to eq(2)
433
+ end
434
+
435
+ it 'rebinds for each evaluation' do
436
+ expect(calculator.evaluate('foo * 2', foo: 2)).to eq(4)
437
+ expect(calculator.evaluate('foo * 2', foo: 4)).to eq(8)
438
+ end
439
+
440
+ it 'accepts strings or symbols for binding keys' do
441
+ expect(calculator.evaluate('foo * 2', foo: 2)).to eq(4)
442
+ expect(calculator.evaluate('foo * 2', 'foo' => 4)).to eq(8)
443
+ end
444
+
445
+ it 'accepts digits in identifiers' do
446
+ expect(calculator.evaluate('foo1 * 2', foo1: 2)).to eq(4)
447
+ expect(calculator.evaluate('foo1 * 2', 'foo1' => 4)).to eq(8)
448
+ expect(calculator.evaluate('1foo * 2', '1foo' => 2)).to eq(4)
449
+ expect(calculator.evaluate('fo1o * 2', fo1o: 4)).to eq(8)
450
+ end
451
+
452
+ it 'accepts special characters in quoted identifiers' do
453
+ expect(calculator.evaluate('`foo1 bar` * 2', "foo1 bar": 2)).to eq(4)
454
+ expect(calculator.evaluate('`foo1-bar` * 2', 'foo1-bar' => 4)).to eq(8)
455
+ expect(calculator.evaluate('`1foo (bar)` * 2', '1foo (bar)' => 2)).to eq(4)
456
+ expect(calculator.evaluate('`fo1o *bar*` * 2', 'fo1o *bar*': 4)).to eq(8)
457
+ end
458
+
459
+ it 'compares string literals with string variables' do
460
+ expect(calculator.evaluate('fruit = "apple"', fruit: 'apple')).to be_truthy
461
+ expect(calculator.evaluate('fruit = "apple"', fruit: 'pear')).to be_falsey
462
+ end
463
+
464
+ it 'performs case-sensitive comparison' do
465
+ expect(calculator.evaluate('fruit = "Apple"', fruit: 'apple')).to be_falsey
466
+ expect(calculator.evaluate('fruit = "Apple"', fruit: 'Apple')).to be_truthy
467
+ end
468
+
469
+ it 'allows binding logical values' do
470
+ expect(calculator.evaluate('some_boolean AND 7 > 5', some_boolean: true)).to be_truthy
471
+ expect(calculator.evaluate('some_boolean AND 7 < 5', some_boolean: true)).to be_falsey
472
+ expect(calculator.evaluate('some_boolean AND 7 > 5', some_boolean: false)).to be_falsey
473
+
474
+ expect(calculator.evaluate('some_boolean OR 7 > 5', some_boolean: true)).to be_truthy
475
+ expect(calculator.evaluate('some_boolean OR 7 < 5', some_boolean: true)).to be_truthy
476
+ expect(calculator.evaluate('some_boolean OR 7 < 5', some_boolean: false)).to be_falsey
477
+ end
478
+
479
+ it 'compares time variables' do
480
+ expect(calculator.evaluate('t1 < t2', t1: Time.local(2017, 1, 1).to_datetime, t2: Time.local(2017, 1, 2).to_datetime)).to be_truthy
481
+ expect(calculator.evaluate('t1 < t2', t1: Time.local(2017, 1, 2).to_datetime, t2: Time.local(2017, 1, 1).to_datetime)).to be_falsy
482
+ expect(calculator.evaluate('t1 > t2', t1: Time.local(2017, 1, 1).to_datetime, t2: Time.local(2017, 1, 2).to_datetime)).to be_falsy
483
+ expect(calculator.evaluate('t1 > t2', t1: Time.local(2017, 1, 2).to_datetime, t2: Time.local(2017, 1, 1).to_datetime)).to be_truthy
484
+ end
485
+
486
+ it 'compares time literals with time variables' do
487
+ expect(calculator.evaluate('t1 < 2017-01-02', t1: Time.local(2017, 1, 1).to_datetime)).to be_truthy
488
+ expect(calculator.evaluate('t1 < 2017-01-02', t1: Time.local(2017, 1, 3).to_datetime)).to be_falsy
489
+ expect(calculator.evaluate('t1 > 2017-01-02', t1: Time.local(2017, 1, 1).to_datetime)).to be_falsy
490
+ expect(calculator.evaluate('t1 > 2017-01-02', t1: Time.local(2017, 1, 3).to_datetime)).to be_truthy
491
+ end
492
+
493
+ describe 'disabling date literals' do
494
+ it 'does not parse formulas with minus signs as dates' do
495
+ calculator = described_class.new(raw_date_literals: false)
496
+ expect(calculator.evaluate!('2020-01-01')).to eq(2018)
497
+ end
498
+ end
499
+
500
+ describe 'supports date arithmetic' do
501
+ it 'from hardcoded string' do
502
+ expect(calculator.evaluate!('2020-01-01 + 30').to_date).to eq(Time.local(2020, 1, 31).to_date)
503
+ expect(calculator.evaluate!('2020-01-01 - 1').to_date).to eq(Time.local(2019, 12, 31).to_date)
504
+ expect(calculator.evaluate!('2020-01-01 - 2019-12-31')).to eq(1)
505
+ expect(calculator.evaluate!('2020-01-01 + duration(1, day)').to_date).to eq(Time.local(2020, 1, 2).to_date)
506
+ expect(calculator.evaluate!('2020-01-01 - duration(1, day)').to_date).to eq(Time.local(2019, 12, 31).to_date)
507
+ expect(calculator.evaluate!('2020-01-01 + duration(30, days)').to_date).to eq(Time.local(2020, 1, 31).to_date)
508
+ expect(calculator.evaluate!('2020-01-01 + duration(1, month)').to_date).to eq(Time.local(2020, 2, 1).to_date)
509
+ expect(calculator.evaluate!('2020-01-01 - duration(1, month)').to_date).to eq(Time.local(2019, 12, 1).to_date)
510
+ expect(calculator.evaluate!('2020-01-01 + duration(30, months)').to_date).to eq(Time.local(2022, 7, 1).to_date)
511
+ expect(calculator.evaluate!('2020-01-01 + duration(1, year)').to_date).to eq(Time.local(2021, 1, 1).to_date)
512
+ expect(calculator.evaluate!('2020-01-01 - duration(1, year)').to_date).to eq(Time.local(2019, 1, 1).to_date)
513
+ expect(calculator.evaluate!('2020-01-01 + duration(30, years)').to_date).to eq(Time.local(2050, 1, 1).to_date)
514
+ end
515
+
516
+ it 'from string variable' do
517
+ value = '2023-01-01'
518
+ value2 = '2022-12-31'
519
+
520
+ expect(calculator.evaluate!('value + duration(1, month)', { value: value }).to_date).to eq(Date.parse('2023-02-01'))
521
+ expect(calculator.evaluate!('value - duration(1, month)', { value: value }).to_date).to eq(Date.parse('2022-12-01'))
522
+ expect(calculator.evaluate!('value - value2', { value: value, value2: value2 })).to eq(1)
523
+ end
524
+
525
+ it 'from date object' do
526
+ value = Date.parse('2023-01-01').to_date
527
+ value2 = Date.parse('2022-12-31').to_date
528
+
529
+ expect(calculator.evaluate!('value + duration(1, month)', { value: value }).to_date).to eq(Date.parse('2023-02-01'))
530
+ expect(calculator.evaluate!('value - duration(1, month)', { value: value }).to_date).to eq(Date.parse('2022-12-01'))
531
+ expect(calculator.evaluate!('value - value2', { value: value, value2: value2 })).to eq(1)
532
+ end
533
+
534
+ it 'from time object' do
535
+ value = Time.local(2023, 7, 13, 10, 42, 11)
536
+ value2 = Time.local(2023, 12, 1, 9, 42, 10)
537
+
538
+ expect(calculator.evaluate!('value + duration(1, month)', { value: value })).to eq(Time.local(2023, 8, 13, 10, 42, 11))
539
+ expect(calculator.evaluate!('value - duration(1, day)', { value: value })).to eq(Time.local(2023, 7, 12, 10, 42, 11))
540
+ expect(calculator.evaluate!('value - duration(1, year)', { value: value })).to eq(Time.local(2022, 7, 13, 10, 42, 11))
541
+ expect(calculator.evaluate!('value2 - value', { value: value, value2: value2 })).to eq(value2 - value)
542
+ expect(calculator.evaluate!('value - 7200', { value: value })).to eq(Time.local(2023, 7, 13, 8, 42, 11))
543
+ end
544
+ end
545
+
546
+ describe 'functions' do
547
+ it 'include IF' do
548
+ expect(calculator.evaluate('if(foo < 8, 10, 20)', foo: 2)).to eq(10)
549
+ expect(calculator.evaluate('if(foo < 8, 10, 20)', foo: 9)).to eq(20)
550
+ expect(calculator.evaluate('if (foo < 8, 10, 20)', foo: 2)).to eq(10)
551
+ expect(calculator.evaluate('if (foo < 8, 10, 20)', foo: 9)).to eq(20)
552
+ end
553
+
554
+ it 'include ROUND' do
555
+ expect(calculator.evaluate('round(8.2)')).to eq(8)
556
+ expect(calculator.evaluate('round(8.8)')).to eq(9)
557
+ expect(calculator.evaluate('round(8.75, 1)')).to eq(BigDecimal('8.8'))
558
+
559
+ expect(calculator.evaluate('ROUND(apples * 0.93)', apples: 10)).to eq(9)
560
+ end
561
+
562
+ it 'include ABS' do
563
+ expect(calculator.evaluate('abs(-2.2)')).to eq(2.2)
564
+ expect(calculator.evaluate('abs(5)')).to eq(5)
565
+
566
+ expect(calculator.evaluate('ABS(x * -1)', x: 10)).to eq(10)
567
+ end
568
+
569
+ it 'include NOT' do
570
+ expect(calculator.evaluate('NOT(some_boolean)', some_boolean: true)).to be_falsey
571
+ expect(calculator.evaluate('NOT(some_boolean)', some_boolean: false)).to be_truthy
572
+
573
+ expect(calculator.evaluate('NOT(some_boolean) AND 7 > 5', some_boolean: true)).to be_falsey
574
+ expect(calculator.evaluate('NOT(some_boolean) OR 7 < 5', some_boolean: false)).to be_truthy
575
+ end
576
+
577
+ it 'evaluates functions with negative numbers' do
578
+ expect(calculator.evaluate('if (-1 < 5, -1, 5)')).to eq(-1)
579
+ expect(calculator.evaluate('if (-1 = -1, -1, 5)')).to eq(-1)
580
+ expect(calculator.evaluate('round(-1.23, 1)')).to eq(BigDecimal('-1.2'))
581
+ expect(calculator.evaluate('NOT(some_boolean) AND -1 > 3', some_boolean: true)).to be_falsey
582
+ end
583
+
584
+ it 'calculates intercept correctly' do
585
+ x_values = [1, 2, 3, 4, 5]
586
+ y_values = [2, 3, 5, 4, 6]
587
+ result = calculator.evaluate('INTERCEPT(x_values, y_values)', x_values: x_values, y_values: y_values)
588
+ expect(result).to be_within(0.001).of(1.3)
589
+ end
590
+
591
+ describe "any" do
592
+ it "enumerates values and returns true if any evaluation is truthy" do
593
+ expect(calculator.evaluate!('any(xs, x, x > 3)', xs: [1, 2, 3, 4])).to be_truthy
594
+ expect(calculator.evaluate!('any(xs, x, x > 3)', xs: 3)).to be_falsy
595
+ expect(calculator.evaluate!('any({1,2,3,4}, x, x > 3)')).to be_truthy
596
+ expect(calculator.evaluate!('any({1,2,3,4}, x, x > 10)')).to be_falsy
597
+ expect(calculator.evaluate!('any(users, u, u.age > 33)', users: [
598
+ {name: "Bob", age: 44},
599
+ {name: "Jane", age: 27}
600
+ ])).to be_truthy
601
+ expect(calculator.evaluate!('any(users, u, u.age < 18)', users: [
602
+ {name: "Bob", age: 44},
603
+ {name: "Jane", age: 27}
604
+ ])).to be_falsy
605
+ end
606
+ end
607
+
608
+ describe "all" do
609
+ it "enumerates values and returns true if all evaluations are truthy" do
610
+ expect(calculator.evaluate!('all(xs, x, x > 3)', xs: [1, 2, 3, 4])).to be_falsy
611
+ expect(calculator.evaluate!('any(xs, x, x > 2)', xs: 3)).to be_truthy
612
+ expect(calculator.evaluate!('all({1,2,3,4}, x, x > 0)')).to be_truthy
613
+ expect(calculator.evaluate!('all({1,2,3,4}, x, x > 10)')).to be_falsy
614
+ expect(calculator.evaluate!('all(users, u, u.age > 33)', users: [
615
+ {name: "Bob", age: 44},
616
+ {name: "Jane", age: 27}
617
+ ])).to be_falsy
618
+ expect(calculator.evaluate!('all(users, u, u.age < 50)', users: [
619
+ {name: "Bob", age: 44},
620
+ {name: "Jane", age: 27}
621
+ ])).to be_truthy
622
+ end
623
+ end
624
+
625
+ describe "map" do
626
+ it "maps values" do
627
+ expect(calculator.evaluate!('map(xs, x, x * 2)', xs: [1, 2, 3, 4])).to eq([2, 4, 6, 8])
628
+ expect(calculator.evaluate!('map({1,2,3,4}, x, x * 2)')).to eq([2, 4, 6, 8])
629
+ expect(calculator.evaluate!('map(users, u, u.age)', users: [
630
+ {name: "Bob", age: 44},
631
+ {name: "Jane", age: 27}
632
+ ])).to eq([44, 27])
633
+ expect(calculator.evaluate!('map(users, u, u.age)', users: [
634
+ {"name" => "Bob", "age" => 44},
635
+ {"name" => "Jane", "age" => 27}
636
+ ])).to eq([44, 27])
637
+ expect(calculator.evaluate!('map(users, u, u.name)', users: [
638
+ {name: "Bob", age: 44},
639
+ {name: "Jane", age: 27}
640
+ ])).to eq(["Bob", "Jane"])
641
+ expect(calculator.evaluate!('map(users, u, u.name)', users: [
642
+ {"name" => "Bob", "age" => 44},
643
+ {"name" => "Jane", "age" => 27}
644
+ ])).to eq(["Bob", "Jane"])
645
+ expect(calculator.evaluate!('map(users, u, IF(u.age < 30, u, null))', users: [
646
+ {"name" => "Bob", "age" => 44},
647
+ {"name" => "Jane", "age" => 27}
648
+ ])).to eq([nil, { "name" => "Jane", "age" => 27 }])
649
+ end
650
+ end
651
+
652
+ describe "pluck" do
653
+ it "plucks values from array of hashes" do
654
+ expect(calculator.evaluate!('pluck(users, age)', users: [
655
+ {name: "Bob", age: 44},
656
+ {name: "Jane", age: 27}
657
+ ])).to eq([44, 27])
658
+ expect(calculator.evaluate!('pluck(users, age)', users: [
659
+ {"name" => "Bob", "age" => 44},
660
+ {"name" => "Jane", "age" => 27}
661
+ ])).to eq([44, 27])
662
+ expect(calculator.evaluate!('pluck(users, name)', users: [
663
+ {name: "Bob", age: 44},
664
+ {name: "Jane", age: 27}
665
+ ])).to eq(["Bob", "Jane"])
666
+ expect(calculator.evaluate!('pluck(users, name)', users: [
667
+ {"name" => "Bob", "age" => 44},
668
+ {"name" => "Jane", "age" => 27}
669
+ ])).to eq(["Bob", "Jane"])
670
+ end
671
+ end
672
+
673
+ it 'evaluates functions with stored variables' do
674
+ calculator.store("multi_color" => true, "number_of_sheets" => 5000, "sheets_per_minute_black" => 2000, "sheets_per_minute_color" => 1000)
675
+ result = calculator.evaluate('number_of_sheets / if(multi_color, sheets_per_minute_color, sheets_per_minute_black)')
676
+ expect(result).to eq(5)
677
+ end
678
+
679
+ describe 'roundup' do
680
+ it 'should work with one argument' do
681
+ expect(calculator.evaluate('roundup(1.234)')).to eq(2)
682
+ end
683
+
684
+ it 'should accept second precision argument like in Office formula' do
685
+ expect(calculator.evaluate('roundup(1.234, 2)')).to eq(1.24)
686
+ end
687
+ end
688
+
689
+ describe 'rounddown' do
690
+ it 'should work with one argument' do
691
+ expect(calculator.evaluate('rounddown(1.234)')).to eq(1)
692
+ end
693
+
694
+ it 'should accept second precision argument like in Office formula' do
695
+ expect(calculator.evaluate('rounddown(1.234, 2)')).to eq(1.23)
696
+ end
697
+ end
698
+ end
699
+
700
+ describe 'nil values' do
701
+ it 'can be used explicitly' do
702
+ expect(calculator.evaluate('IF(null, 1, 2)')).to eq(2)
703
+ end
704
+
705
+ it 'can be assigned to a variable' do
706
+ expect(calculator.evaluate('IF(foo, 1, 2)', foo: nil)).to eq(2)
707
+ end
708
+
709
+ it 'are carried across middle terms' do
710
+ results = calculator.solve!(
711
+ choice: 'IF(bar, 1, 2)',
712
+ bar: 'foo',
713
+ foo: nil)
714
+ expect(results).to eq(
715
+ choice: 2,
716
+ bar: nil,
717
+ foo: nil
718
+ )
719
+ end
720
+
721
+ it 'raise errors when used in arithmetic operations' do
722
+ expect {
723
+ calculator.solve!(more_apples: "apples + 1", apples: nil)
724
+ }.to raise_error(Dentaku::ArgumentError)
725
+ end
726
+ end
727
+
728
+ describe 'case statements' do
729
+ let(:formula) {
730
+ <<-FORMULA
731
+ CASE fruit
732
+ WHEN 'apple'
733
+ THEN 1 * quantity
734
+ WHEN 'banana'
735
+ THEN 2 * quantity
736
+ ELSE
737
+ 3 * quantity
738
+ END
739
+ FORMULA
740
+ }
741
+
742
+ it 'handles complex then statements' do
743
+ expect(calculator.evaluate(formula, quantity: 3, fruit: 'apple')).to eq(3)
744
+ expect(calculator.evaluate(formula, quantity: 3, fruit: 'banana')).to eq(6)
745
+ end
746
+
747
+ it 'evaluates case statement as part of a larger expression' do
748
+ expect(calculator.evaluate("2 + #{formula}", quantity: 3, fruit: 'apple')).to eq(5)
749
+ expect(calculator.evaluate("2 + #{formula}", quantity: 3, fruit: 'banana')).to eq(8)
750
+ expect(calculator.evaluate("2 + #{formula}", quantity: 3, fruit: 'kiwi')).to eq(11)
751
+ expect(calculator.evaluate("#{formula} + 2", quantity: 3, fruit: 'apple')).to eq(5)
752
+ expect(calculator.evaluate("#{formula} + 2", quantity: 3, fruit: 'banana')).to eq(8)
753
+ expect(calculator.evaluate("#{formula} + 2", quantity: 3, fruit: 'kiwi')).to eq(11)
754
+ end
755
+
756
+ it 'handles complex when statements' do
757
+ formula = <<-FORMULA
758
+ CASE number
759
+ WHEN (2 * 2)
760
+ THEN 1
761
+ WHEN (2 * 3)
762
+ THEN 2
763
+ END
764
+ FORMULA
765
+ expect(calculator.evaluate(formula, number: 4)).to eq(1)
766
+ expect(calculator.evaluate(formula, number: 6)).to eq(2)
767
+ end
768
+
769
+ it 'raises an exception when no match and there is no default value' do
770
+ formula = <<-FORMULA
771
+ CASE number
772
+ WHEN 42
773
+ THEN 1
774
+ END
775
+ FORMULA
776
+ expect { calculator.evaluate!(formula, number: 2) }
777
+ .to raise_error("No block matched the switch value '2'")
778
+ end
779
+
780
+ it 'handles a default else statement' do
781
+ expect(calculator.evaluate(formula, quantity: 1, fruit: 'banana')).to eq(2)
782
+ expect(calculator.evaluate(formula, quantity: 1, fruit: 'orange')).to eq(3)
783
+ end
784
+
785
+ it 'handles nested case statements' do
786
+ formula = <<-FORMULA
787
+ CASE fruit
788
+ WHEN 'apple'
789
+ THEN 1 * quantity
790
+ WHEN 'banana'
791
+ THEN
792
+ CASE quantity
793
+ WHEN 1 THEN 2
794
+ WHEN 10 THEN
795
+ CASE type
796
+ WHEN 'organic' THEN 5
797
+ END
798
+ END
799
+ END
800
+ FORMULA
801
+ value = calculator.evaluate(
802
+ formula,
803
+ type: 'organic',
804
+ quantity: 10,
805
+ fruit: 'banana')
806
+ expect(value).to eq(5)
807
+ end
808
+
809
+ it 'handles nested case statements with case-sensitivity' do
810
+ formula = <<-FORMULA
811
+ CASE fruit
812
+ WHEN 'apple'
813
+ THEN 1 * quantity
814
+ WHEN 'banana'
815
+ THEN
816
+ CASE QUANTITY
817
+ WHEN 1 THEN 2
818
+ WHEN 10 THEN
819
+ CASE type
820
+ WHEN 'organic' THEN 5
821
+ END
822
+ END
823
+ END
824
+ FORMULA
825
+ value = with_case_sensitivity.evaluate(
826
+ formula,
827
+ type: 'organic',
828
+ quantity: 1,
829
+ QUANTITY: 10,
830
+ fruit: 'banana')
831
+ expect(value).to eq(5)
832
+ end
833
+
834
+ it 'handles multiple nested case statements' do
835
+ formula = <<-FORMULA
836
+ CASE fruit
837
+ WHEN 'apple'
838
+ THEN
839
+ CASE quantity
840
+ WHEN 2 THEN 3
841
+ END
842
+ WHEN 'banana'
843
+ THEN
844
+ CASE quantity
845
+ WHEN 1 THEN 2
846
+ END
847
+ END
848
+ FORMULA
849
+ value = calculator.evaluate(
850
+ formula,
851
+ quantity: 1,
852
+ fruit: 'banana')
853
+ expect(value).to eq(2)
854
+
855
+ value = calculator.evaluate(
856
+ formula,
857
+ quantity: 2,
858
+ fruit: 'apple')
859
+ expect(value).to eq(3)
860
+ end
861
+ end
862
+
863
+ describe 'math support' do
864
+ Math.methods(false).each do |method|
865
+ it "includes `#{method}`" do
866
+ if Math.method(method).arity == 2
867
+ expect(calculator.evaluate("#{method}(x,y)", x: 1, y: '2')).to eq(Math.send(method, 1, 2))
868
+ expect(calculator.evaluate("#{method}(x,y) + 1", x: 1, y: '2')).to be_within(0.00001).of(Math.send(method, 1, 2) + 1)
869
+ expect { calculator.evaluate!("#{method}(x)", x: 1) }.to raise_error(Dentaku::ParseError)
870
+ else
871
+ expect(calculator.evaluate("#{method}(1)")).to eq(Math.send(method, 1))
872
+ unless [:atanh, :frexp, :lgamma].include?(method)
873
+ expect(calculator.evaluate("#{method}(1) + 1")).to be_within(0.00001).of(Math.send(method, 1) + 1)
874
+ end
875
+ end
876
+ end
877
+ end
878
+
879
+ it 'defines a properly named class to support AST marshaling' do
880
+ expect {
881
+ Marshal.dump(calculator.ast('SQRT(20)'))
882
+ }.not_to raise_error
883
+ end
884
+
885
+ it 'properly handles a Math::DomainError' do
886
+ expect(calculator.evaluate('asin(2)')).to be_nil
887
+ expect { calculator.evaluate!('asin(2)') }.to raise_error(Dentaku::MathDomainError)
888
+ end
889
+ end
890
+
891
+ describe 'disable_cache' do
892
+ before do
893
+ allow(Dentaku).to receive(:cache_ast?) { true }
894
+ end
895
+
896
+ it 'disables the AST cache' do
897
+ expect(calculator.disable_cache { |c| c.cache_ast? }).to be false
898
+ end
899
+
900
+ it 'calculates normally' do
901
+ expect(calculator.disable_cache { |c| c.evaluate("2 + 2") }).to eq(4)
902
+ end
903
+ end
904
+
905
+ describe 'clear_cache' do
906
+ before do
907
+ allow(Dentaku).to receive(:cache_ast?) { true }
908
+
909
+ calculator.ast("1+1")
910
+ calculator.ast("pineapples * 5")
911
+ calculator.ast("pi * radius ^ 2")
912
+
913
+ def calculator.ast_cache
914
+ @ast_cache
915
+ end
916
+ end
917
+
918
+ it 'clears all items from cache' do
919
+ expect(calculator.ast_cache.length).to eq(3)
920
+ calculator.clear_cache
921
+ expect(calculator.ast_cache.keys).to be_empty
922
+ end
923
+
924
+ it 'clears one item from cache' do
925
+ calculator.clear_cache("1+1")
926
+ expect(calculator.ast_cache.keys.sort).to eq([
927
+ 'pi * radius ^ 2',
928
+ 'pineapples * 5',
929
+ ])
930
+ end
931
+
932
+ it 'clears items matching regex from cache' do
933
+ calculator.clear_cache(/^pi/)
934
+ expect(calculator.ast_cache.keys.sort).to eq(['1+1'])
935
+ end
936
+ end
937
+
938
+ describe 'string functions' do
939
+ it 'concatenates strings' do
940
+ expect(
941
+ calculator.evaluate('CONCAT(s1, s2, s3)', 's1' => 'ab', 's2' => 'cd', 's3' => 'ef')
942
+ ).to eq('abcdef')
943
+ end
944
+
945
+ it 'manipulates string arguments' do
946
+ expect(calculator.evaluate("left('ABCD', 2)")).to eq('AB')
947
+ expect(calculator.evaluate("right('ABCD', 2)")).to eq('CD')
948
+ expect(calculator.evaluate("mid('ABCD', 2, 2)")).to eq('BC')
949
+ expect(calculator.evaluate("len('ABCD')")).to eq(4)
950
+ expect(calculator.evaluate("find('BC', 'ABCD')")).to eq(2)
951
+ expect(calculator.evaluate("substitute('ABCD', 'BC', 'XY')")).to eq('AXYD')
952
+ expect(calculator.evaluate("contains('BC', 'ABCD')")).to be_truthy
953
+ end
954
+ end
955
+
956
+ describe 'zero-arity functions' do
957
+ it 'can be used in formulas' do
958
+ calculator.add_function(:two, :numeric, -> { 2 })
959
+ expect(calculator.evaluate("max(two(), 1)")).to eq(2)
960
+ expect(calculator.evaluate("max(1, two())")).to eq(2)
961
+ end
962
+ end
963
+
964
+ describe 'aliases' do
965
+ it 'accepts aliases as instance option' do
966
+ expect(with_aliases.evaluate('rrround(5.1)')).to eq(5)
967
+ end
968
+ end
969
+
970
+ describe 'nested_data' do
971
+ it 'default to nested data enabled' do
972
+ expect(calculator.nested_data_support).to be_truthy
973
+ end
974
+
975
+ it 'allow opt out of nested data support' do
976
+ expect(without_nested_data.nested_data_support).to be_falsy
977
+ end
978
+
979
+ it 'should allow optout of nested hash' do
980
+ expect do
981
+ without_nested_data.solve!('a.b.c')
982
+ end.to raise_error(Dentaku::UnboundVariableError)
983
+ end
984
+ end
985
+
986
+ describe 'identifier cache' do
987
+ it 'reduces call count by caching results of resolved identifiers' do
988
+ called = 0
989
+ calculator.store_formula("A1", "B1+B1+B1")
990
+ calculator.store_formula("B1", "C1+C1+C1+C1")
991
+ calculator.store_formula("C1", "D1")
992
+ calculator.store("D1", proc { called += 1; 1 })
993
+
994
+ expect {
995
+ Dentaku.enable_identifier_cache!
996
+ }.to change {
997
+ called = 0
998
+ calculator.evaluate("A1")
999
+ called
1000
+ }.from(12).to(1)
1001
+ end
1002
+ end
1003
+ end