451 lines
15 KiB
Ruby
451 lines
15 KiB
Ruby
require 'spec_helper'
|
|
require 'dentaku/calculator'
|
|
|
|
describe Dentaku::Calculator do
|
|
let(:calculator) { described_class.new }
|
|
let(:with_memory) { described_class.new.store(apples: 3) }
|
|
|
|
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
|
|
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
|
|
end
|
|
|
|
describe 'dependencies' do
|
|
it "finds dependencies in a generic statement" do
|
|
expect(calculator.dependencies("bob + dole / 3")).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
|
|
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
|
|
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
|
|
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 '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(unbound)).to be_nil
|
|
expect(calculator.evaluate(unbound) { :bar }).to eq :bar
|
|
expect(calculator.evaluate(unbound) { |e| e }).to eq unbound
|
|
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
|
|
|
|
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 'explicit NULL' do
|
|
it 'can be used in IF statements' do
|
|
expect(calculator.evaluate('IF(null, 1, 2)')).to eq(2)
|
|
end
|
|
|
|
it 'can be used in IF statements when passed in' do
|
|
expect(calculator.evaluate('IF(foo, 1, 2)', foo: nil)).to eq(2)
|
|
end
|
|
|
|
it 'nil values 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 'raises errors when used in arithmetic operation' do
|
|
expect {
|
|
calculator.solve!(more_apples: "apples + 1", apples: nil)
|
|
}.to raise_error(Dentaku::ArgumentError)
|
|
end
|
|
end
|
|
|
|
describe 'case statements' do
|
|
it 'handles complex then statements' do
|
|
formula = <<-FORMULA
|
|
CASE fruit
|
|
WHEN 'apple'
|
|
THEN (1 * quantity)
|
|
WHEN 'banana'
|
|
THEN (2 * quantity)
|
|
END
|
|
FORMULA
|
|
expect(calculator.evaluate(formula, quantity: 3, fruit: 'apple')).to eq(3)
|
|
expect(calculator.evaluate(formula, quantity: 3, fruit: 'banana')).to eq(6)
|
|
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
|
|
formula = <<-FORMULA
|
|
CASE fruit
|
|
WHEN 'apple'
|
|
THEN 1 * quantity
|
|
WHEN 'banana'
|
|
THEN 2 * quantity
|
|
ELSE
|
|
3 * quantity
|
|
END
|
|
FORMULA
|
|
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
|
|
end
|
|
|
|
describe 'math functions' do
|
|
Math.methods(false).each do |method|
|
|
it method do
|
|
if Math.method(method).arity == 2
|
|
expect(calculator.evaluate("#{method}(1,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 two strings' do
|
|
expect(
|
|
calculator.evaluate('CONCAT(s1, s2)', 's1' => 'abc', 's2' => 'def')
|
|
).to eq 'abcdef'
|
|
end
|
|
end
|
|
end
|