torm 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +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
|