torm 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +15 -0
- data/.travis.yml +4 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +70 -0
- data/Rakefile +10 -0
- data/lib/torm.rb +31 -0
- data/lib/torm/rules_engine.rb +187 -0
- data/lib/torm/tools.rb +24 -0
- data/lib/torm/version.rb +3 -0
- data/test/minitest_helper.rb +5 -0
- data/test/torm/rules_engine_test.rb +143 -0
- data/test/torm/tools_test.rb +38 -0
- data/test/torm_test.rb +14 -0
- data/torm.gemspec +31 -0
- metadata +119 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 28b08b8646e0f7e2e8345d914abc082a018c9d50
|
4
|
+
data.tar.gz: f0a53d0a8ac7b90010733cadfb0d7f82f43d0efb
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: faabf0c69a4bf2efb60baf50307b6e67661145c9ddc25a0dfd6ac282508e9e529e18a41ada804a8b9a847a3fab149d296190c57661a57a9b325e855fceca61e7
|
7
|
+
data.tar.gz: acaa6b02d117fd38a6765d122170172346decf05634f3dffaea74818aef871ba559477b5617ef1b31a5594dad3b9e447eaab7a377e8b340820a60d72fbb94719
|
data/.gitignore
ADDED
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2014 Wes Oldenbeuving
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,70 @@
|
|
1
|
+
# Torm
|
2
|
+
|
3
|
+
Torm is a rules engine build in Ruby. It is named after [Torm](http://forgottenrealms.wikia.com/wiki/Torm), the Forgotten Realms god of Law.
|
4
|
+
|
5
|
+
## Installation
|
6
|
+
|
7
|
+
Add this line to your application's Gemfile:
|
8
|
+
|
9
|
+
```ruby
|
10
|
+
gem 'torm'
|
11
|
+
```
|
12
|
+
|
13
|
+
And then execute:
|
14
|
+
|
15
|
+
$ bundle
|
16
|
+
|
17
|
+
Or install it yourself as:
|
18
|
+
|
19
|
+
$ gem install torm
|
20
|
+
|
21
|
+
## Usage
|
22
|
+
|
23
|
+
```ruby
|
24
|
+
# Setup a new engine
|
25
|
+
engine = Torm::RulesEngine.new
|
26
|
+
|
27
|
+
# Define a bunch of rules: name, value/range, priority, conditions
|
28
|
+
# Each rule should at least have a default value without any conditions.
|
29
|
+
# Default priorities: :law > :coc > :experiment > :default
|
30
|
+
engine.add_rule 'FSK level', 1..18, :default
|
31
|
+
engine.add_rule 'FSK level', { maximum: 16 }, :coc, country: 'FR'
|
32
|
+
engine.add_rule 'FSK level', { minimum: 12 }, :default, sexy: true
|
33
|
+
|
34
|
+
# Let the engine decide which rules apply and how they intersect
|
35
|
+
engine.decide('FSK level', country: 'NL') # => {minimum: 1, maximum: 18}
|
36
|
+
engine.decide('FSK level', country: 'FR', sexy: true) # => {minimum: 12, maximum: 16}
|
37
|
+
```
|
38
|
+
|
39
|
+
## How rules are evaluated
|
40
|
+
|
41
|
+
* Policy origins dictate priority.
|
42
|
+
* The lowest priority are the defaults. This is our company policy.
|
43
|
+
* On top of defaults we can run experiments.
|
44
|
+
* The Code of Conduct (usually specific to a country + payment method) overrules our policies and experiments
|
45
|
+
* Law (usually specific to a country) overrules everything
|
46
|
+
* Rules have a set of zero or more conditions
|
47
|
+
* Each condition must be met in order for a rule to be relevant
|
48
|
+
* On equal policy level, more specific rules (more conditions) overrule less specific ones. Rationale: "We usually don't do this, except when it's summer."
|
49
|
+
* Decisions take a rule and a bunch of environment conditions
|
50
|
+
* We gather all rules, then filter irrelevant rules based on environment conditions
|
51
|
+
* Because rules are stored in order of priority, the first rule remaining is the one that applies the best.
|
52
|
+
|
53
|
+
## Versioning
|
54
|
+
|
55
|
+
Torm tries to follow Semantic Versioning 2.0.0, this means that given a version number MAJOR.MINOR.PATCH, it will increment the:
|
56
|
+
|
57
|
+
* MAJOR version when you make incompatible API changes,
|
58
|
+
* MINOR version when you add functionality in a backwards-compatible manner, and
|
59
|
+
* PATCH version when you make backwards-compatible bug fixes.
|
60
|
+
|
61
|
+
As long as the MAJOR version is 0, all bets are off as the library has not been declared stable yet.
|
62
|
+
In this case, treat MINOR version changes as a sign to check the changelog for breaking chagnes.
|
63
|
+
|
64
|
+
## Contributing
|
65
|
+
|
66
|
+
1. Fork it ( https://github.com/narnach/torm/fork )
|
67
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
68
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
69
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
70
|
+
5. Create a new Pull Request
|
data/Rakefile
ADDED
data/lib/torm.rb
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
# External dependencies
|
2
|
+
require 'multi_json'
|
3
|
+
|
4
|
+
# No internal dependencies
|
5
|
+
require 'torm/version'
|
6
|
+
require 'torm/tools'
|
7
|
+
|
8
|
+
# This is where the magic happens
|
9
|
+
require 'torm/rules_engine'
|
10
|
+
|
11
|
+
module Torm
|
12
|
+
class << self
|
13
|
+
include Tools
|
14
|
+
|
15
|
+
attr_accessor :instance, :default_rules_file
|
16
|
+
|
17
|
+
def instance
|
18
|
+
@instance ||= RulesEngine.load || RulesEngine.new
|
19
|
+
end
|
20
|
+
|
21
|
+
def default_rules_file
|
22
|
+
@default_rules_file ||= File.expand_path('tmp/rules.json')
|
23
|
+
end
|
24
|
+
|
25
|
+
# Load an engine with the current rules, yield it (to add rules) and then save it if rules were added.
|
26
|
+
def set_defaults(engine: instance)
|
27
|
+
yield engine
|
28
|
+
engine.save if engine.dirty?
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,187 @@
|
|
1
|
+
module Torm
|
2
|
+
class RulesEngine
|
3
|
+
# Policies (priorities) in order of important -> least important.
|
4
|
+
DEFAULT_POLICIES = [:law, :coc, :experiment, :default].freeze
|
5
|
+
|
6
|
+
attr_reader :rules
|
7
|
+
attr_accessor :verbose, :policies, :conditions_whitelist
|
8
|
+
attr_accessor :dirty
|
9
|
+
|
10
|
+
def initialize(rules: {}, conditions_whitelist: {}, dirty: false, policies: DEFAULT_POLICIES.dup, verbose: false)
|
11
|
+
@rules = rules
|
12
|
+
@conditions_whitelist = conditions_whitelist
|
13
|
+
@dirty = dirty
|
14
|
+
@policies = policies
|
15
|
+
@verbose = verbose
|
16
|
+
end
|
17
|
+
|
18
|
+
# Have any rules been added since the last save or load?
|
19
|
+
def dirty?
|
20
|
+
@dirty
|
21
|
+
end
|
22
|
+
|
23
|
+
# Add a new rule.
|
24
|
+
# Will mark the engine as dirty when a rules was added.
|
25
|
+
# @return [Torm::RulesEngine] (self) Returns the engine that rules were added to.
|
26
|
+
def add_rule(name, value, policy, conditions={})
|
27
|
+
raise "Illegal policy: #{policy.inspect}, must be one of: #{policies.inspect}" unless policies.include?(policy)
|
28
|
+
rules_array = rules_for(name)
|
29
|
+
value = { minimum: value.min, maximum: value.max } if Range === value
|
30
|
+
new_rule = { value: value.freeze, policy: policy, conditions: conditions.freeze }.freeze
|
31
|
+
unless rules_array.include?(new_rule)
|
32
|
+
rules_array << new_rule
|
33
|
+
# Sort rules so that the highest policy level is sorted first and then the most complex rule before the more general ones
|
34
|
+
rules_array.sort_by! { |rule| [policies.index(rule[:policy]), -rule[:conditions].size] }
|
35
|
+
conditions_whitelist_for(name).merge conditions.keys
|
36
|
+
@dirty = true
|
37
|
+
end
|
38
|
+
self
|
39
|
+
end
|
40
|
+
|
41
|
+
def decide(name, environment={})
|
42
|
+
raise "Unknown rule: #{name.inspect}" unless rules.has_key?(name)
|
43
|
+
environment = Torm.symbolize_keys(environment)
|
44
|
+
decision_environment = Torm.slice(environment, *conditions_whitelist_for(name))
|
45
|
+
answer = make_decision(name, decision_environment)
|
46
|
+
#Rails.logger.debug "DECISION: #{answer.inspect} (#{name.inspect} -> #{environment.inspect})"
|
47
|
+
answer
|
48
|
+
end
|
49
|
+
|
50
|
+
def as_hash
|
51
|
+
{
|
52
|
+
policies: policies,
|
53
|
+
rules: rules
|
54
|
+
}
|
55
|
+
end
|
56
|
+
|
57
|
+
def to_json
|
58
|
+
MultiJson.dump(as_hash)
|
59
|
+
end
|
60
|
+
|
61
|
+
# Load an engine from JSON. This means we can export rules engines across systems: store rules in 1 place, run them 'everywhere' at native speed.
|
62
|
+
# Due to the high number of symbols we use, we have to convert the JSON string data for each rule on import.
|
63
|
+
# Good thing: we should only have to do this once on boot.
|
64
|
+
def self.from_json(json)
|
65
|
+
dump = MultiJson.load(json)
|
66
|
+
data = {
|
67
|
+
policies: dump['policies'].map(&:to_sym),
|
68
|
+
}
|
69
|
+
engine = new(data)
|
70
|
+
dump['rules'].each do |name, rules|
|
71
|
+
rules.each do |rule|
|
72
|
+
value = rule['value']
|
73
|
+
value = Torm.symbolize_keys(value) if Hash === value
|
74
|
+
policy = rule['policy'].to_sym
|
75
|
+
conditions = Torm.symbolize_keys(rule['conditions'])
|
76
|
+
engine.add_rule(name, value, policy, conditions)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
engine.dirty = false
|
80
|
+
engine
|
81
|
+
end
|
82
|
+
|
83
|
+
# Where we store the rules file.
|
84
|
+
def self.rules_file
|
85
|
+
Rails.root.join('tmp', 'rules.json').to_s
|
86
|
+
end
|
87
|
+
|
88
|
+
# Load rules from a file and create a new engine for it.
|
89
|
+
# Note: this does *not* replace the Torm::RulesEngine.instance, you have to do this yourself if required.
|
90
|
+
#
|
91
|
+
# @return [Torm::RulesEngine] A new engine with the loaded rules
|
92
|
+
def self.load(rules_file: Torm.default_rules_file)
|
93
|
+
if File.exist?(rules_file)
|
94
|
+
json = File.read(rules_file)
|
95
|
+
self.from_json(json)
|
96
|
+
else
|
97
|
+
nil
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
# Save the current rules to a file.
|
102
|
+
def save(rules_file: self.class.rules_file)
|
103
|
+
Torm.atomic_save(rules_file, to_json + "\n")
|
104
|
+
@dirty = false
|
105
|
+
nil
|
106
|
+
end
|
107
|
+
|
108
|
+
private
|
109
|
+
|
110
|
+
# TODO: Refactor once useful
|
111
|
+
def make_decision(name, environment={})
|
112
|
+
# Fetch all rules for this decision. Duplicate to allow us to manipulate the Array with #reject!
|
113
|
+
relevant_rules = rules_for(name).dup
|
114
|
+
|
115
|
+
# Filter through all rules. Eliminate the rules not matching our environment.
|
116
|
+
relevant_rules.reject! do |rule|
|
117
|
+
reject_rule = false
|
118
|
+
rule[:conditions].each do |condition, value|
|
119
|
+
if environment.has_key?(condition)
|
120
|
+
if environment[condition] == value
|
121
|
+
# This rule condition applies to our environment, so evaluate the next condition
|
122
|
+
next
|
123
|
+
else
|
124
|
+
# The rule has a condition which is a mismatch with our environment, so it does not apply
|
125
|
+
reject_rule = true
|
126
|
+
break
|
127
|
+
end
|
128
|
+
else
|
129
|
+
# The rule is more specific than our environment, so it does not apply
|
130
|
+
reject_rule = true
|
131
|
+
break
|
132
|
+
end
|
133
|
+
end
|
134
|
+
reject_rule
|
135
|
+
end
|
136
|
+
|
137
|
+
# Check the remaining rules in order of priority
|
138
|
+
result = nil
|
139
|
+
relevant_rules.each do |rule|
|
140
|
+
rule_value = rule[:value]
|
141
|
+
case rule_value
|
142
|
+
when Hash
|
143
|
+
result ||= rule_value.dup
|
144
|
+
# Lower-priority rules can decrease a maximum value, but not increase it
|
145
|
+
if rule_value[:maximum]
|
146
|
+
if result[:maximum]
|
147
|
+
result[:maximum] = rule_value[:maximum] if rule_value[:maximum] < result[:maximum]
|
148
|
+
else
|
149
|
+
result[:maximum] = rule_value[:maximum]
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
# Lower-priority rules can increase a minimum value, but not decrease it
|
154
|
+
if rule_value[:minimum]
|
155
|
+
if result[:minimum]
|
156
|
+
result[:minimum] = rule_value[:minimum] if rule_value[:minimum] > result[:minimum]
|
157
|
+
else
|
158
|
+
result[:minimum] = rule_value[:minimum]
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
# Minimum above maximum is invalid, so reject the result and return nil
|
163
|
+
return nil if result[:minimum] && result[:maximum] && result[:minimum] > result[:maximum]
|
164
|
+
else
|
165
|
+
return rule_value
|
166
|
+
end
|
167
|
+
end
|
168
|
+
result
|
169
|
+
end
|
170
|
+
|
171
|
+
def conditions_whitelist_for(name)
|
172
|
+
conditions_whitelist[name] ||= Set.new
|
173
|
+
end
|
174
|
+
|
175
|
+
def rules_for(name)
|
176
|
+
rules[name] ||= []
|
177
|
+
end
|
178
|
+
|
179
|
+
def puts(message)
|
180
|
+
Kernel.puts(message) if verbose
|
181
|
+
end
|
182
|
+
|
183
|
+
def pp(object)
|
184
|
+
Kernel.pp(object) if verbose
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
data/lib/torm/tools.rb
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
module Torm::Tools
|
2
|
+
# Save data to a temporary file, then rename it to the final file.
|
3
|
+
def atomic_save(target_file, data)
|
4
|
+
tmp_file = target_file + ".#{Process.pid}.tmp"
|
5
|
+
File.open(tmp_file, 'w') { |f| f.write data }
|
6
|
+
File.rename(tmp_file, target_file)
|
7
|
+
end
|
8
|
+
|
9
|
+
# Return a new Hash with all keys symbolized
|
10
|
+
def symbolize_keys(hash)
|
11
|
+
symbolized_hash = {}
|
12
|
+
hash.each { |k, v| symbolized_hash[k.to_sym] = v }
|
13
|
+
symbolized_hash
|
14
|
+
end
|
15
|
+
|
16
|
+
# Return a new Hash with only they white listed keys
|
17
|
+
def slice(hash, *white_listed_keys)
|
18
|
+
sliced_hash = {}
|
19
|
+
white_listed_keys.each do |key|
|
20
|
+
sliced_hash[key] = hash[key] if hash.has_key?(key)
|
21
|
+
end
|
22
|
+
sliced_hash
|
23
|
+
end
|
24
|
+
end
|
data/lib/torm/version.rb
ADDED
@@ -0,0 +1,143 @@
|
|
1
|
+
require 'minitest_helper'
|
2
|
+
|
3
|
+
describe Torm::RulesEngine do
|
4
|
+
let(:engine) { Torm::RulesEngine.new }
|
5
|
+
|
6
|
+
describe '.decide' do
|
7
|
+
describe 'basic decision making' do
|
8
|
+
before(:each) do
|
9
|
+
engine.add_rule 'show unsubscribe link', false, :default
|
10
|
+
engine.add_rule 'show unsubscribe link', true, :coc, country: 'FR'
|
11
|
+
end
|
12
|
+
|
13
|
+
it 'should pick the default without any conditions' do
|
14
|
+
# This means it should *not* apply rules which only apply to conditions we don't have
|
15
|
+
engine.decide('show unsubscribe link').must_equal false
|
16
|
+
end
|
17
|
+
|
18
|
+
it 'should pick a specific rule over the default' do
|
19
|
+
engine.decide('show unsubscribe link', country: 'FR').must_equal true
|
20
|
+
end
|
21
|
+
|
22
|
+
it 'should ignore conditions which do not match' do
|
23
|
+
engine.decide('show unsubscribe link', country: 'NL', happy: true, season: :summer).must_equal false
|
24
|
+
|
25
|
+
engine.add_rule 'show unsubscribe link', true, :default, season: :summer
|
26
|
+
engine.decide('show unsubscribe link', country: 'NL', happy: true, season: :summer).must_equal true
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
describe 'range behavior' do
|
31
|
+
it 'should support maximum' do
|
32
|
+
engine.add_rule 'FSK level', { maximum: 18 }, :default
|
33
|
+
engine.add_rule 'FSK level', { maximum: 16 }, :coc, referrer: 'google'
|
34
|
+
engine.add_rule 'FSK level', { maximum: 12 }, :coc, referrer: 'disney'
|
35
|
+
engine.add_rule 'FSK level', { maximum: 16 }, :coc, country: 'FR'
|
36
|
+
engine.add_rule 'FSK level', { maximum: 16 }, :coc, country: 'DE'
|
37
|
+
engine.add_rule 'FSK level', { maximum: 12 }, :law, country: 'TR'
|
38
|
+
|
39
|
+
# Default
|
40
|
+
engine.decide('FSK level').must_equal({ maximum: 18 })
|
41
|
+
# Default
|
42
|
+
engine.decide('FSK level', country: 'NL').must_equal({ maximum: 18 })
|
43
|
+
# CoC for DE
|
44
|
+
engine.decide('FSK level', country: 'DE').must_equal({ maximum: 16 })
|
45
|
+
# ref=google
|
46
|
+
engine.decide('FSK level', country: 'NL', referrer: 'google').must_equal({ maximum: 16 })
|
47
|
+
|
48
|
+
# should start with 12+ for TR, and not modify it to 16+ for ref=google
|
49
|
+
engine.decide('FSK level', country: 'TR', referrer: 'google').must_equal({ maximum: 12 })
|
50
|
+
|
51
|
+
# should start with 16+ for FR, but tighten it to 12+ for ref=disney
|
52
|
+
engine.decide('FSK level', country: 'FR', referrer: 'disney').must_equal({ maximum: 12 })
|
53
|
+
end
|
54
|
+
|
55
|
+
it 'should support minimum' do
|
56
|
+
engine.add_rule 'FSK level', { minimum: 1 }, :default
|
57
|
+
engine.add_rule 'FSK level', { minimum: 12 }, :default, sexy: true
|
58
|
+
engine.add_rule 'FSK level', { minimum: 16 }, :default, softcore: true
|
59
|
+
engine.add_rule 'FSK level', { minimum: 18 }, :default, hardcore: true
|
60
|
+
|
61
|
+
|
62
|
+
# Query individual FSK thresholds
|
63
|
+
engine.decide('FSK level').must_equal({ minimum: 1 })
|
64
|
+
engine.decide('FSK level', sexy: true).must_equal({ minimum: 12 })
|
65
|
+
engine.decide('FSK level', softcore: true).must_equal({ minimum: 16 })
|
66
|
+
engine.decide('FSK level', hardcore: true).must_equal({ minimum: 18 })
|
67
|
+
|
68
|
+
# Combine multiple FSK threshold
|
69
|
+
#engine.verbose = true
|
70
|
+
engine.decide('FSK level', hardcore: true, sexy: true).must_equal({ minimum: 18 })
|
71
|
+
engine.decide('FSK level', softcore: true, sexy: true).must_equal({ minimum: 16 })
|
72
|
+
end
|
73
|
+
|
74
|
+
it 'should combine minimum and maximum' do
|
75
|
+
engine.add_rule 'FSK level', { minimum: 1, maximum: 18 }, :default
|
76
|
+
engine.add_rule 'FSK level', { maximum: 16 }, :coc, referrer: 'google'
|
77
|
+
engine.add_rule 'FSK level', { maximum: 12 }, :coc, referrer: 'disney'
|
78
|
+
engine.add_rule 'FSK level', { maximum: 16 }, :coc, country: 'FR'
|
79
|
+
engine.add_rule 'FSK level', { maximum: 16 }, :coc, country: 'DE'
|
80
|
+
engine.add_rule 'FSK level', { maximum: 12 }, :law, country: 'TR'
|
81
|
+
|
82
|
+
engine.add_rule 'FSK level', { minimum: 12 }, :default, sexy: true
|
83
|
+
engine.add_rule 'FSK level', { minimum: 16 }, :default, softcore: true
|
84
|
+
engine.add_rule 'FSK level', { minimum: 18 }, :default, hardcore: true
|
85
|
+
|
86
|
+
engine.decide('FSK level', country: 'NL').must_equal({ minimum: 1, maximum: 18 })
|
87
|
+
engine.decide('FSK level', country: 'FR').must_equal({ minimum: 1, maximum: 16 })
|
88
|
+
engine.decide('FSK level', country: 'FR', sexy: true).must_equal({ minimum: 12, maximum: 16 })
|
89
|
+
|
90
|
+
# Return nil because of conflicting requirements: softcore is 16+, TR is 12-
|
91
|
+
engine.decide('FSK level', country: 'TR', softcore: true).must_equal nil
|
92
|
+
end
|
93
|
+
|
94
|
+
it 'should allow range syntax' do
|
95
|
+
engine.add_rule 'FSK level', 1..18, :default
|
96
|
+
engine.add_rule 'FSK level', { maximum: 16 }, :coc, country: 'FR'
|
97
|
+
engine.add_rule 'FSK level', { minimum: 12 }, :default, sexy: true
|
98
|
+
|
99
|
+
engine.decide('FSK level', country: 'NL').must_equal({ minimum: 1, maximum: 18 })
|
100
|
+
engine.decide('FSK level', country: 'FR', sexy: true).must_equal({ minimum: 12, maximum: 16 })
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
describe '#to_json' do
|
106
|
+
it 'should export all rules as a Hash' do
|
107
|
+
engine.add_rule 'FSK level', 1..18, :default
|
108
|
+
engine.add_rule 'FSK level', { maximum: 16 }, :coc, country: 'FR'
|
109
|
+
engine.add_rule 'FSK level', { minimum: 12 }, :default, sexy: true
|
110
|
+
|
111
|
+
rule_hash = {
|
112
|
+
policies: [:law, :coc, :experiment, :default],
|
113
|
+
rules: {
|
114
|
+
'FSK level' => [
|
115
|
+
{ policy: :coc, value: { maximum: 16 }, conditions: { country: 'FR' } },
|
116
|
+
{ policy: :default, value: { minimum: 12 }, conditions: { sexy: true } },
|
117
|
+
{ policy: :default, value: { minimum: 1, maximum: 18 }, conditions: {} },
|
118
|
+
]
|
119
|
+
}
|
120
|
+
}
|
121
|
+
engine.as_hash.must_equal rule_hash
|
122
|
+
|
123
|
+
engine.to_json.must_equal MultiJson.dump(engine.as_hash)
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
describe '.from_json' do
|
128
|
+
it 'should return a working rules engine' do
|
129
|
+
engine.add_rule 'FSK level', 1..18, :default
|
130
|
+
engine.add_rule 'FSK level', { maximum: 16 }, :coc, country: 'FR'
|
131
|
+
engine.add_rule 'FSK level', { minimum: 12 }, :default, sexy: true
|
132
|
+
|
133
|
+
engine.decide('FSK level', country: 'NL').must_equal({ minimum: 1, maximum: 18 })
|
134
|
+
engine.decide('FSK level', country: 'FR', sexy: true).must_equal({ minimum: 12, maximum: 16 })
|
135
|
+
|
136
|
+
engine2 = Torm::RulesEngine.from_json(engine.to_json)
|
137
|
+
engine2.as_hash.must_equal engine.as_hash
|
138
|
+
|
139
|
+
engine2.decide('FSK level', country: 'NL').must_equal({ minimum: 1, maximum: 18 })
|
140
|
+
engine2.decide('FSK level', country: 'FR', sexy: true).must_equal({ minimum: 12, maximum: 16 })
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
require 'minitest_helper'
|
2
|
+
|
3
|
+
# This module is included in Torm, so we use that to test its behavior.
|
4
|
+
describe Torm::Tools do
|
5
|
+
describe '#atomic_save' do
|
6
|
+
tmp_file = 'tmp/atomic.test'
|
7
|
+
after do
|
8
|
+
File.delete(tmp_file) if File.exist?(tmp_file)
|
9
|
+
Dir.delete('tmp') if File.exist?('tmp')
|
10
|
+
end
|
11
|
+
|
12
|
+
it 'should save data to a file' do
|
13
|
+
Dir.mkdir('tmp')
|
14
|
+
Torm.atomic_save(tmp_file, 'test')
|
15
|
+
assert File.exist?(tmp_file)
|
16
|
+
File.read(tmp_file).must_equal 'test'
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
describe '#symbolize_keys' do
|
21
|
+
it 'should convert string keys to symbols' do
|
22
|
+
Torm.symbolize_keys({ 'a' => 'b', :c => :d }).must_equal({ a: 'b', c: :d })
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
describe '#slice' do
|
27
|
+
it 'should return a hash with only the white listed keys' do
|
28
|
+
hash = {
|
29
|
+
foo: 1,
|
30
|
+
baz: 3
|
31
|
+
}
|
32
|
+
Torm.slice(hash, :foo, :bar).must_equal({ foo: 1 })
|
33
|
+
|
34
|
+
# Ensure we did not modify the original Hash
|
35
|
+
hash[:baz].must_equal 3
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
data/test/torm_test.rb
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
require 'minitest_helper'
|
2
|
+
|
3
|
+
describe Torm do
|
4
|
+
describe '.instance' do
|
5
|
+
it 'should load an Engine with default rules when there is a default rules file'
|
6
|
+
it 'should instantiate a new Engine when there are no default rules'
|
7
|
+
it 'should return the same instance when one has already been accessed'
|
8
|
+
end
|
9
|
+
|
10
|
+
describe '.set_defaults' do
|
11
|
+
it 'should yield the engine'
|
12
|
+
it 'should save the engine when dirty after running the block'
|
13
|
+
end
|
14
|
+
end
|
data/torm.gemspec
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'torm/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = 'torm'
|
8
|
+
spec.version = Torm::VERSION
|
9
|
+
spec.authors = ['Wes Oldenbeuving']
|
10
|
+
spec.email = ['narnach@gmail.com']
|
11
|
+
spec.summary = %q{Ruby rules engine}
|
12
|
+
spec.description = %q{Rules engine. Named after the Forgotten Realms god of Law.}
|
13
|
+
spec.homepage = ''
|
14
|
+
spec.license = 'MIT'
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0")
|
17
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
|
+
spec.require_paths = ['lib']
|
20
|
+
|
21
|
+
# Ruby 2.1 introduces required named keywords
|
22
|
+
spec.required_ruby_version = '>= 2.1.0'
|
23
|
+
|
24
|
+
# MultiJson follow Semantic Versioning, so any 1.x should work.
|
25
|
+
spec.add_dependency 'multi_json', '~> 1.0'
|
26
|
+
|
27
|
+
# Defaults from generating the gemspec
|
28
|
+
spec.add_development_dependency 'bundler', '~> 1.7'
|
29
|
+
spec.add_development_dependency 'rake', '~> 10.0'
|
30
|
+
spec.add_development_dependency 'minitest'
|
31
|
+
end
|
metadata
ADDED
@@ -0,0 +1,119 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: torm
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Wes Oldenbeuving
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2014-10-24 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: multi_json
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: bundler
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.7'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.7'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rake
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '10.0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '10.0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: minitest
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
description: Rules engine. Named after the Forgotten Realms god of Law.
|
70
|
+
email:
|
71
|
+
- narnach@gmail.com
|
72
|
+
executables: []
|
73
|
+
extensions: []
|
74
|
+
extra_rdoc_files: []
|
75
|
+
files:
|
76
|
+
- ".gitignore"
|
77
|
+
- ".travis.yml"
|
78
|
+
- Gemfile
|
79
|
+
- LICENSE.txt
|
80
|
+
- README.md
|
81
|
+
- Rakefile
|
82
|
+
- lib/torm.rb
|
83
|
+
- lib/torm/rules_engine.rb
|
84
|
+
- lib/torm/tools.rb
|
85
|
+
- lib/torm/version.rb
|
86
|
+
- test/minitest_helper.rb
|
87
|
+
- test/torm/rules_engine_test.rb
|
88
|
+
- test/torm/tools_test.rb
|
89
|
+
- test/torm_test.rb
|
90
|
+
- torm.gemspec
|
91
|
+
homepage: ''
|
92
|
+
licenses:
|
93
|
+
- MIT
|
94
|
+
metadata: {}
|
95
|
+
post_install_message:
|
96
|
+
rdoc_options: []
|
97
|
+
require_paths:
|
98
|
+
- lib
|
99
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - ">="
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: 2.1.0
|
104
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
105
|
+
requirements:
|
106
|
+
- - ">="
|
107
|
+
- !ruby/object:Gem::Version
|
108
|
+
version: '0'
|
109
|
+
requirements: []
|
110
|
+
rubyforge_project:
|
111
|
+
rubygems_version: 2.4.1
|
112
|
+
signing_key:
|
113
|
+
specification_version: 4
|
114
|
+
summary: Ruby rules engine
|
115
|
+
test_files:
|
116
|
+
- test/minitest_helper.rb
|
117
|
+
- test/torm/rules_engine_test.rb
|
118
|
+
- test/torm/tools_test.rb
|
119
|
+
- test/torm_test.rb
|