require 'spec_helper' require 'dentaku' describe Dentaku::Calculator do let(:calculator) { described_class.new } let(:with_memory) { described_class.new.store(apples: 3) } let(:with_aliases) { described_class.new(aliases: { round: ['rrround'] }) } let(:without_nested_data) { described_class.new(nested_data_support: false) } it 'evaluates an expression' do expect(calculator.evaluate('7+3')).to eq(10) expect(calculator.evaluate('2 -1')).to eq(1) expect(calculator.evaluate('-1 + 2')).to eq(1) expect(calculator.evaluate('1 - 2')).to eq(-1) expect(calculator.evaluate('1 - - 2')).to eq(3) expect(calculator.evaluate('-1 - - 2')).to eq(1) expect(calculator.evaluate('1 - - - 2')).to eq(-1) expect(calculator.evaluate('(-1 + 2)')).to eq(1) expect(calculator.evaluate('-(1 + 2)')).to eq(-3) expect(calculator.evaluate('2 ^ - 1')).to eq(0.5) expect(calculator.evaluate('2 ^ -(3 - 2)')).to eq(0.5) expect(calculator.evaluate('(2 + 3) - 1')).to eq(4) expect(calculator.evaluate('(-2 + 3) - 1')).to eq(0) expect(calculator.evaluate('(-2 - 3) - 1')).to eq(-6) expect(calculator.evaluate('1 + -(2 ^ 2)')).to eq(-3) expect(calculator.evaluate('3 + -num', num: 2)).to eq(1) expect(calculator.evaluate('-num + 3', num: 2)).to eq(1) expect(calculator.evaluate('10 ^ 2')).to eq(100) expect(calculator.evaluate('0 * 10 ^ -5')).to eq(0) expect(calculator.evaluate('3 + 0 * -3')).to eq(3) expect(calculator.evaluate('3 + 0 / -3')).to eq(3) expect(calculator.evaluate('15 % 8')).to eq(7) expect(calculator.evaluate('(((695759/735000)^(1/(1981-1991)))-1)*1000').round(4)).to eq(5.5018) expect(calculator.evaluate('0.253/0.253')).to eq(1) expect(calculator.evaluate('0.253/d', d: 0.253)).to eq(1) expect(calculator.evaluate('10 + x', x: 'abc')).to be_nil expect(calculator.evaluate('x * y', x: '.123', y: '100')).to eq(12.3) expect(calculator.evaluate('a/b', a: '10', b: '2')).to eq(5) expect(calculator.evaluate('t + 1*24*60*60', t: Time.local(2017, 1, 1))).to eq(Time.local(2017, 1, 2)) expect(calculator.evaluate("2 | 3 * 9")).to eq (27) expect(calculator.evaluate("2 & 3 * 9")).to eq (2) expect(calculator.evaluate("5%")).to eq (0.05) end describe 'evaluate' do it 'returns nil when formula has error' do expect(calculator.evaluate('1 + + 1')).to be_nil end it 'suppresses unbound variable errors' do expect(calculator.evaluate('AND(a,b)')).to be_nil expect(calculator.evaluate('IF(a, 1, 0)')).to be_nil expect(calculator.evaluate('MAX(a,b)')).to be_nil expect(calculator.evaluate('MIN(a,b)')).to be_nil expect(calculator.evaluate('NOT(a)')).to be_nil expect(calculator.evaluate('OR(a,b)')).to be_nil expect(calculator.evaluate('ROUND(a)')).to be_nil expect(calculator.evaluate('ROUNDDOWN(a)')).to be_nil expect(calculator.evaluate('ROUNDUP(a)')).to be_nil expect(calculator.evaluate('SUM(a,b)')).to be_nil end it 'suppresses numeric coercion errors' do expect(calculator.evaluate('MAX(a,b)', a: nil, b: nil)).to be_nil expect(calculator.evaluate('MIN(a,b)', a: nil, b: nil)).to be_nil expect(calculator.evaluate('ROUND(a)', a: nil)).to be_nil expect(calculator.evaluate('ROUNDDOWN(a)', a: nil)).to be_nil expect(calculator.evaluate('ROUNDUP(a)', a: nil)).to be_nil expect(calculator.evaluate('SUM(a,b)', a: nil, b: nil)).to be_nil end it 'treats explicit nil as logical false' do expect(calculator.evaluate('AND(a,b)', a: nil, b: nil)).to be_falsy expect(calculator.evaluate('IF(a,1,0)', a: nil, b: nil)).to eq(0) expect(calculator.evaluate('NOT(a)', a: nil, b: nil)).to be_truthy expect(calculator.evaluate('OR(a,b)', a: nil, b: nil)).to be_falsy end end describe 'evaluate!' do it 'raises exception when formula has error' do expect { calculator.evaluate!('1 + + 1') }.to raise_error(Dentaku::ParseError) expect { calculator.evaluate!('(1 > 5) OR LEFT("abc", 1)') }.to raise_error(Dentaku::ParseError) end it 'raises unbound variable errors' do expect { calculator.evaluate!('AND(a,b)') }.to raise_error(Dentaku::UnboundVariableError) expect { calculator.evaluate!('IF(a, 1, 0)') }.to raise_error(Dentaku::UnboundVariableError) expect { calculator.evaluate!('MAX(a,b)') }.to raise_error(Dentaku::UnboundVariableError) expect { calculator.evaluate!('MIN(a,b)') }.to raise_error(Dentaku::UnboundVariableError) expect { calculator.evaluate!('NOT(a)') }.to raise_error(Dentaku::UnboundVariableError) expect { calculator.evaluate!('OR(a,b)') }.to raise_error(Dentaku::UnboundVariableError) expect { calculator.evaluate!('ROUND(a)') }.to raise_error(Dentaku::UnboundVariableError) expect { calculator.evaluate!('ROUNDDOWN(a)') }.to raise_error(Dentaku::UnboundVariableError) expect { calculator.evaluate!('ROUNDUP(a)') }.to raise_error(Dentaku::UnboundVariableError) expect { calculator.evaluate!('SUM(a,b)') }.to raise_error(Dentaku::UnboundVariableError) end it 'raises numeric coersion errors' do expect { calculator.evaluate!('MAX(a,b)', a: nil, b: nil) }.to raise_error(Dentaku::ArgumentError) expect { calculator.evaluate!('MIN(a,b)', a: nil, b: nil) }.to raise_error(Dentaku::ArgumentError) expect { calculator.evaluate!('ROUND(a)', a: nil) }.to raise_error(Dentaku::ArgumentError) expect { calculator.evaluate!('ROUNDDOWN(a)', a: nil) }.to raise_error(Dentaku::ArgumentError) expect { calculator.evaluate!('ROUNDUP(a)', a: nil) }.to raise_error(Dentaku::ArgumentError) expect { calculator.evaluate!('SUM(a,b)', a: nil, b: nil) }.to raise_error(Dentaku::ArgumentError) end end it 'supports unicode characters in identifiers' do expect(calculator.evaluate("ρ * 2", ρ: 2)).to eq (4) end describe 'memory' do it { expect(calculator).to be_empty } it { expect(with_memory).not_to be_empty } it { expect(with_memory.clear).to be_empty } it 'discards local values' do expect(calculator.evaluate('pears * 2', pears: 5)).to eq(10) expect(calculator).to be_empty end it 'can store the value `false`' do calculator.store('i_am_false', false) expect(calculator.evaluate!('i_am_false')).to eq false end it 'can store multiple values' do calculator.store(first: 1, second: 2) expect(calculator.evaluate!('first')).to eq 1 expect(calculator.evaluate!('second')).to eq 2 end it 'stores formulas' do calculator.store_formula('area', 'length * width') expect(calculator.evaluate!('area', length: 5, width: 5)).to eq 25 end it 'stores nested hashes' do calculator.store(a: {basket: {of: 'apples'}}, b: 2) expect(calculator.evaluate!('a.basket.of')).to eq 'apples' expect(calculator.evaluate!('b')).to eq 2 end it 'stores arrays' do calculator.store(a: [1, 2, 3]) expect(calculator.evaluate!('a[0]')).to eq 1 expect(calculator.evaluate!('a[x]', x: 1)).to eq 2 expect(calculator.evaluate!('a[x+1]', x: 1)).to eq 3 end it 'evaluates arrays' do expect(calculator.evaluate([1, 2, 3])).to eq([1, 2, 3]) expect(calculator.evaluate!('{1,2,3}')).to eq([1, 2, 3]) end end describe 'dependencies' do it "finds dependencies in a generic statement" do expect(calculator.dependencies("bob + dole / 3")).to eq(['bob', 'dole']) end it "ignores dependencies passed in context" do expect(calculator.dependencies("a + b", a: 1)).to eq(['b']) end it "finds dependencies in formula arguments" do allow(Dentaku).to receive(:cache_ast?) { true } expect(calculator.dependencies("CONCAT(bob, dole)")).to eq(['bob', 'dole']) end it "doesn't consider variables in memory as dependencies" do expect(with_memory.dependencies("apples + oranges")).to eq(['oranges']) end it "finds no dependencies in array literals" do expect(calculator.dependencies([1, 2, 3])).to eq([]) end end describe 'solve!' do it "evaluates properly with variables, even if some in memory" do expect(with_memory.solve!( weekly_fruit_budget: "weekly_apple_budget + pear * 4", weekly_apple_budget: "apples * 7", pear: "1" )).to eq(pear: 1, weekly_apple_budget: 21, weekly_fruit_budget: 25) end it "preserves hash keys" do expect(calculator.solve!( 'meaning_of_life' => 'age + kids', 'age' => 40, 'kids' => 2 )).to eq('age' => 40, 'kids' => 2, 'meaning_of_life' => 42) end it "lets you know about a cycle if one occurs" do expect do calculator.solve!(health: "happiness", happiness: "health") end.to raise_error(TSort::Cyclic) end it 'is case-insensitive' do result = with_memory.solve!(total_fruit: "Apples + pears", pears: 10) expect(result[:total_fruit]).to eq 13 end it "lets you know if a variable is unbound" do expect { calculator.solve!(more_apples: "apples + 1") }.to raise_error(Dentaku::UnboundVariableError) end it 'can reference stored formulas' do calculator.store_formula("base_area", "length * width") calculator.store_formula("volume", "base_area * height") result = calculator.solve!( weight: "volume * 5.432", height: "3", length: "2", width: "length * 2", ) expect(result[:weight]).to eq 130.368 end it 'raises an exception if there are cyclic dependencies' do expect { calculator.solve!( make_money: "have_money", have_money: "make_money" ) }.to raise_error(TSort::Cyclic) end end describe 'solve' do it "returns :undefined when variables are unbound" do expressions = {more_apples: "apples + 1"} expect(calculator.solve(expressions)).to eq(more_apples: :undefined) end it "allows passing in a custom value to an error handler" do expressions = {more_apples: "apples + 1"} expect(calculator.solve(expressions) { :foo }) .to eq(more_apples: :foo) end it "solves remainder of expressions with unbound variable" do calculator.store(peaches: 1, oranges: 1) expressions = { more_apples: "apples + 1", more_peaches: "peaches + 1" } result = calculator.solve(expressions) expect(calculator.memory).to eq("peaches" => 1, "oranges" => 1) expect(result).to eq( more_apples: :undefined, more_peaches: 2 ) end it "solves remainder of expressions when one cannot be evaluated" do result = calculator.solve( conditional: "IF(d != 0, ratio, 0)", ratio: "10/d", d: 0, ) expect(result).to eq( conditional: 0, ratio: :undefined, d: 0, ) end it 'returns undefined if there are cyclic dependencies' do expect { result = calculator.solve( make_money: "have_money", have_money: "make_money" ) expect(result).to eq( make_money: :undefined, have_money: :undefined ) }.not_to raise_error end end it 'evaluates a statement with no variables' do expect(calculator.evaluate('5+3')).to eq(8) expect(calculator.evaluate('(1+1+1)/3*100')).to eq(100) end it 'evaluates negation' do expect(calculator.evaluate('-negative', negative: -1)).to eq(1) expect(calculator.evaluate('-negative', negative: '-1')).to eq(1) expect(calculator.evaluate('-negative - 1', negative: '-1')).to eq(0) expect(calculator.evaluate('-negative - 1', negative: '1')).to eq(-2) expect(calculator.evaluate('-(negative) - 1', negative: '1')).to eq(-2) end it 'fails to evaluate unbound statements' do unbound = 'foo * 1.5' expect { calculator.evaluate!(unbound) }.to raise_error(Dentaku::UnboundVariableError) expect { calculator.evaluate!(unbound) }.to raise_error do |error| expect(error.unbound_variables).to eq ['foo'] end expect { calculator.evaluate!('a + b') }.to raise_error do |error| expect(error.unbound_variables).to eq ['a', 'b'] end expect(calculator.evaluate(unbound)).to be_nil expect(calculator.evaluate(unbound) { :bar }).to eq :bar expect(calculator.evaluate(unbound) { |e| e }).to eq unbound end it 'fails to evaluate incomplete statements' do ['true AND', 'a a ^&'].each do |statement| expect { calculator.evaluate!(statement) }.to raise_error(Dentaku::ParseError) end end it 'evaluates unbound statements given a binding in memory' do expect(calculator.evaluate('foo * 1.5', foo: 2)).to eq(3) expect(calculator.bind(monkeys: 3).evaluate('monkeys < 7')).to be_truthy expect(calculator.evaluate('monkeys / 1.5')).to eq(2) end it 'rebinds for each evaluation' do expect(calculator.evaluate('foo * 2', foo: 2)).to eq(4) expect(calculator.evaluate('foo * 2', foo: 4)).to eq(8) end it 'accepts strings or symbols for binding keys' do expect(calculator.evaluate('foo * 2', foo: 2)).to eq(4) expect(calculator.evaluate('foo * 2', 'foo' => 4)).to eq(8) end it 'accepts digits in identifiers' do expect(calculator.evaluate('foo1 * 2', foo1: 2)).to eq(4) expect(calculator.evaluate('foo1 * 2', 'foo1' => 4)).to eq(8) expect(calculator.evaluate('1foo * 2', '1foo' => 2)).to eq(4) expect(calculator.evaluate('fo1o * 2', fo1o: 4)).to eq(8) end it 'compares string literals with string variables' do expect(calculator.evaluate('fruit = "apple"', fruit: 'apple')).to be_truthy expect(calculator.evaluate('fruit = "apple"', fruit: 'pear')).to be_falsey end it 'performs case-sensitive comparison' do expect(calculator.evaluate('fruit = "Apple"', fruit: 'apple')).to be_falsey expect(calculator.evaluate('fruit = "Apple"', fruit: 'Apple')).to be_truthy end it 'allows binding logical values' do expect(calculator.evaluate('some_boolean AND 7 > 5', some_boolean: true)).to be_truthy expect(calculator.evaluate('some_boolean AND 7 < 5', some_boolean: true)).to be_falsey expect(calculator.evaluate('some_boolean AND 7 > 5', some_boolean: false)).to be_falsey expect(calculator.evaluate('some_boolean OR 7 > 5', some_boolean: true)).to be_truthy expect(calculator.evaluate('some_boolean OR 7 < 5', some_boolean: true)).to be_truthy expect(calculator.evaluate('some_boolean OR 7 < 5', some_boolean: false)).to be_falsey end it 'compares Time variables' do expect(calculator.evaluate('t1 < t2', t1: Time.local(2017, 1, 1).to_datetime, t2: Time.local(2017, 1, 2).to_datetime)).to be_truthy expect(calculator.evaluate('t1 < t2', t1: Time.local(2017, 1, 2).to_datetime, t2: Time.local(2017, 1, 1).to_datetime)).to be_falsy expect(calculator.evaluate('t1 > t2', t1: Time.local(2017, 1, 1).to_datetime, t2: Time.local(2017, 1, 2).to_datetime)).to be_falsy expect(calculator.evaluate('t1 > t2', t1: Time.local(2017, 1, 2).to_datetime, t2: Time.local(2017, 1, 1).to_datetime)).to be_truthy end it 'compares Time literals with Time variables' do expect(calculator.evaluate('t1 < 2017-01-02', t1: Time.local(2017, 1, 1).to_datetime)).to be_truthy expect(calculator.evaluate('t1 < 2017-01-02', t1: Time.local(2017, 1, 3).to_datetime)).to be_falsy expect(calculator.evaluate('t1 > 2017-01-02', t1: Time.local(2017, 1, 1).to_datetime)).to be_falsy expect(calculator.evaluate('t1 > 2017-01-02', t1: Time.local(2017, 1, 3).to_datetime)).to be_truthy end describe 'functions' do it 'include IF' do expect(calculator.evaluate('if(foo < 8, 10, 20)', foo: 2)).to eq(10) expect(calculator.evaluate('if(foo < 8, 10, 20)', foo: 9)).to eq(20) expect(calculator.evaluate('if (foo < 8, 10, 20)', foo: 2)).to eq(10) expect(calculator.evaluate('if (foo < 8, 10, 20)', foo: 9)).to eq(20) end it 'include ROUND' do expect(calculator.evaluate('round(8.2)')).to eq(8) expect(calculator.evaluate('round(8.8)')).to eq(9) expect(calculator.evaluate('round(8.75, 1)')).to eq(BigDecimal.new('8.8')) expect(calculator.evaluate('ROUND(apples * 0.93)', apples: 10)).to eq(9) end it 'include NOT' do expect(calculator.evaluate('NOT(some_boolean)', some_boolean: true)).to be_falsey expect(calculator.evaluate('NOT(some_boolean)', some_boolean: false)).to be_truthy expect(calculator.evaluate('NOT(some_boolean) AND 7 > 5', some_boolean: true)).to be_falsey expect(calculator.evaluate('NOT(some_boolean) OR 7 < 5', some_boolean: false)).to be_truthy end it 'evaluates functions with negative numbers' do expect(calculator.evaluate('if (-1 < 5, -1, 5)')).to eq(-1) expect(calculator.evaluate('if (-1 = -1, -1, 5)')).to eq(-1) expect(calculator.evaluate('round(-1.23, 1)')).to eq(BigDecimal.new('-1.2')) expect(calculator.evaluate('NOT(some_boolean) AND -1 > 3', some_boolean: true)).to be_falsey end it 'evaluates functions with stored variables' do calculator.store("multi_color" => true, "number_of_sheets" => 5000, "sheets_per_minute_black" => 2000, "sheets_per_minute_color" => 1000) result = calculator.evaluate('number_of_sheets / if(multi_color, sheets_per_minute_color, sheets_per_minute_black)') expect(result).to eq(5) end describe 'roundup' do it 'should work with one argument' do expect(calculator.evaluate('roundup(1.234)')).to eq(2) end it 'should accept second precision argument like in Office formula' do expect(calculator.evaluate('roundup(1.234, 2)')).to eq(1.24) end end describe 'rounddown' do it 'should work with one argument' do expect(calculator.evaluate('rounddown(1.234)')).to eq(1) end it 'should accept second precision argument like in Office formula' do expect(calculator.evaluate('rounddown(1.234, 2)')).to eq(1.23) end end end describe 'nil values' do it 'can be used explicitly' do expect(calculator.evaluate('IF(null, 1, 2)')).to eq(2) end it 'can be assigned to a variable' do expect(calculator.evaluate('IF(foo, 1, 2)', foo: nil)).to eq(2) end it 'are carried across middle terms' do results = calculator.solve!( choice: 'IF(bar, 1, 2)', bar: 'foo', foo: nil) expect(results).to eq( choice: 2, bar: nil, foo: nil ) end it 'raise errors when used in arithmetic operations' do expect { calculator.solve!(more_apples: "apples + 1", apples: nil) }.to raise_error(Dentaku::ArgumentError) end end describe 'case statements' do let(:formula) { <<-FORMULA CASE fruit WHEN 'apple' THEN 1 * quantity WHEN 'banana' THEN 2 * quantity ELSE 3 * quantity END FORMULA } it 'handles complex then statements' do expect(calculator.evaluate(formula, quantity: 3, fruit: 'apple')).to eq(3) expect(calculator.evaluate(formula, quantity: 3, fruit: 'banana')).to eq(6) end it 'evaluates case statement as part of a larger expression' do expect(calculator.evaluate("2 + #{formula}", quantity: 3, fruit: 'apple')).to eq(5) expect(calculator.evaluate("2 + #{formula}", quantity: 3, fruit: 'banana')).to eq(8) expect(calculator.evaluate("2 + #{formula}", quantity: 3, fruit: 'kiwi')).to eq(11) expect(calculator.evaluate("#{formula} + 2", quantity: 3, fruit: 'apple')).to eq(5) expect(calculator.evaluate("#{formula} + 2", quantity: 3, fruit: 'banana')).to eq(8) expect(calculator.evaluate("#{formula} + 2", quantity: 3, fruit: 'kiwi')).to eq(11) end it 'handles complex when statements' do formula = <<-FORMULA CASE number WHEN (2 * 2) THEN 1 WHEN (2 * 3) THEN 2 END FORMULA expect(calculator.evaluate(formula, number: 4)).to eq(1) expect(calculator.evaluate(formula, number: 6)).to eq(2) end it 'throws an exception when no match and there is no default value' do formula = <<-FORMULA CASE number WHEN 42 THEN 1 END FORMULA expect { calculator.evaluate(formula, number: 2) } .to raise_error("No block matched the switch value '2'") end it 'handles a default else statement' do expect(calculator.evaluate(formula, quantity: 1, fruit: 'banana')).to eq(2) expect(calculator.evaluate(formula, quantity: 1, fruit: 'orange')).to eq(3) end it 'handles nested case statements' do formula = <<-FORMULA CASE fruit WHEN 'apple' THEN 1 * quantity WHEN 'banana' THEN CASE quantity WHEN 1 THEN 2 WHEN 10 THEN CASE type WHEN 'organic' THEN 5 END END END FORMULA value = calculator.evaluate( formula, type: 'organic', quantity: 10, fruit: 'banana') expect(value).to eq(5) end it 'handles multiple nested case statements' do formula = <<-FORMULA CASE fruit WHEN 'apple' THEN CASE quantity WHEN 2 THEN 3 END WHEN 'banana' THEN CASE quantity WHEN 1 THEN 2 END END FORMULA value = calculator.evaluate( formula, quantity: 1, fruit: 'banana') expect(value).to eq(2) value = calculator.evaluate( formula, quantity: 2, fruit: 'apple') expect(value).to eq(3) end end describe 'math functions' do Math.methods(false).each do |method| it method do if Math.method(method).arity == 2 expect(calculator.evaluate("#{method}(x,y)", x: 1, y: '2')).to eq Math.send(method, 1, 2) else expect(calculator.evaluate("#{method}(1)")).to eq Math.send(method, 1) end end end end describe 'disable_cache' do before do allow(Dentaku).to receive(:cache_ast?) { true } end it 'disables the AST cache' do expect(calculator.disable_cache { |c| c.cache_ast? }).to be false end it 'calculates normally' do expect(calculator.disable_cache { |c| c.evaluate("2 + 2") }).to eq(4) end end describe 'clear_cache' do before do allow(Dentaku).to receive(:cache_ast?) { true } calculator.ast("1+1") calculator.ast("pineapples * 5") calculator.ast("pi * radius ^ 2") def calculator.ast_cache @ast_cache end end it 'clears all items from cache' do expect(calculator.ast_cache.length).to eq 3 calculator.clear_cache expect(calculator.ast_cache.keys).to be_empty end it 'clears one item from cache' do calculator.clear_cache("1+1") expect(calculator.ast_cache.keys.sort).to eq([ 'pi * radius ^ 2', 'pineapples * 5', ]) end it 'clears items matching regex from cache' do calculator.clear_cache(/^pi/) expect(calculator.ast_cache.keys.sort).to eq(['1+1']) end end describe 'string functions' do it 'concatenates strings' do expect( calculator.evaluate('CONCAT(s1, s2, s3)', 's1' => 'ab', 's2' => 'cd', 's3' => 'ef') ).to eq 'abcdef' end end describe 'zero-arity functions' do it 'can be used in formulas' do calculator.add_function(:two, :numeric, -> { 2 }) expect(calculator.evaluate("max(two(), 1)")).to eq 2 expect(calculator.evaluate("max(1, two())")).to eq 2 end end describe 'aliases' do it 'accepts aliases as instance option' do expect(with_aliases.evaluate('rrround(5.1)')).to eq 5 end end describe 'nested_data' do it 'default to nested data enabled' do expect(calculator.nested_data_support).to be_truthy end it 'allow opt out of nested data support' do expect(without_nested_data.nested_data_support).to be_falsy end it 'should allow optout of nested hash' do expect do without_nested_data.solve!('a.b.c') end.to raise_error(Dentaku::UnboundVariableError) end end end