decision_agent 0.2.0 → 0.3.0
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 +4 -4
- data/README.md +41 -1
- data/bin/decision_agent +104 -0
- data/lib/decision_agent/dmn/adapter.rb +135 -0
- data/lib/decision_agent/dmn/cache.rb +306 -0
- data/lib/decision_agent/dmn/decision_graph.rb +327 -0
- data/lib/decision_agent/dmn/decision_tree.rb +192 -0
- data/lib/decision_agent/dmn/errors.rb +30 -0
- data/lib/decision_agent/dmn/exporter.rb +217 -0
- data/lib/decision_agent/dmn/feel/evaluator.rb +797 -0
- data/lib/decision_agent/dmn/feel/functions.rb +420 -0
- data/lib/decision_agent/dmn/feel/parser.rb +349 -0
- data/lib/decision_agent/dmn/feel/simple_parser.rb +276 -0
- data/lib/decision_agent/dmn/feel/transformer.rb +372 -0
- data/lib/decision_agent/dmn/feel/types.rb +276 -0
- data/lib/decision_agent/dmn/importer.rb +77 -0
- data/lib/decision_agent/dmn/model.rb +197 -0
- data/lib/decision_agent/dmn/parser.rb +191 -0
- data/lib/decision_agent/dmn/testing.rb +333 -0
- data/lib/decision_agent/dmn/validator.rb +315 -0
- data/lib/decision_agent/dmn/versioning.rb +229 -0
- data/lib/decision_agent/dmn/visualizer.rb +513 -0
- data/lib/decision_agent/dsl/condition_evaluator.rb +3 -0
- data/lib/decision_agent/dsl/schema_validator.rb +2 -1
- data/lib/decision_agent/evaluators/dmn_evaluator.rb +221 -0
- data/lib/decision_agent/version.rb +1 -1
- data/lib/decision_agent/web/dmn_editor.rb +426 -0
- data/lib/decision_agent/web/public/dmn-editor.css +596 -0
- data/lib/decision_agent/web/public/dmn-editor.html +250 -0
- data/lib/decision_agent/web/public/dmn-editor.js +553 -0
- data/lib/decision_agent/web/public/index.html +3 -0
- data/lib/decision_agent/web/public/styles.css +21 -0
- data/lib/decision_agent/web/server.rb +465 -0
- data/spec/ab_testing/ab_testing_agent_spec.rb +174 -0
- data/spec/auth/rbac_adapter_spec.rb +228 -0
- data/spec/dmn/decision_graph_spec.rb +282 -0
- data/spec/dmn/decision_tree_spec.rb +203 -0
- data/spec/dmn/feel/errors_spec.rb +18 -0
- data/spec/dmn/feel/functions_spec.rb +400 -0
- data/spec/dmn/feel/simple_parser_spec.rb +274 -0
- data/spec/dmn/feel/types_spec.rb +176 -0
- data/spec/dmn/feel_parser_spec.rb +489 -0
- data/spec/dmn/hit_policy_spec.rb +202 -0
- data/spec/dmn/integration_spec.rb +226 -0
- data/spec/examples.txt +1846 -1570
- data/spec/fixtures/dmn/complex_decision.dmn +81 -0
- data/spec/fixtures/dmn/invalid_structure.dmn +31 -0
- data/spec/fixtures/dmn/simple_decision.dmn +40 -0
- data/spec/monitoring/metrics_collector_spec.rb +37 -35
- data/spec/monitoring/monitored_agent_spec.rb +14 -11
- data/spec/performance_optimizations_spec.rb +10 -3
- data/spec/thread_safety_spec.rb +10 -2
- data/spec/web_ui_rack_spec.rb +294 -0
- metadata +65 -1
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
require "decision_agent/dmn/decision_tree"
|
|
5
|
+
|
|
6
|
+
RSpec.describe DecisionAgent::Dmn::DecisionTree do
|
|
7
|
+
describe "basic tree structure" do
|
|
8
|
+
let(:tree) do
|
|
9
|
+
described_class.new(id: "tree1", name: "Loan Approval Tree")
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it "creates a tree with root node" do
|
|
13
|
+
expect(tree.root).to be_a(DecisionAgent::Dmn::TreeNode)
|
|
14
|
+
expect(tree.root.id).to eq("root")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it "allows adding children to nodes" do
|
|
18
|
+
child1 = DecisionAgent::Dmn::TreeNode.new(id: "child1", label: "Check Age")
|
|
19
|
+
tree.root.add_child(child1)
|
|
20
|
+
|
|
21
|
+
expect(tree.root.children).to include(child1)
|
|
22
|
+
expect(child1.parent).to eq(tree.root)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
describe "tree evaluation" do
|
|
27
|
+
let(:tree) do
|
|
28
|
+
# Build a simple decision tree for loan approval
|
|
29
|
+
tree = described_class.new(id: "loan_tree", name: "Loan Approval")
|
|
30
|
+
|
|
31
|
+
# Root node
|
|
32
|
+
root = tree.root
|
|
33
|
+
|
|
34
|
+
# First level - check age
|
|
35
|
+
age_check = DecisionAgent::Dmn::TreeNode.new(
|
|
36
|
+
id: "age_check",
|
|
37
|
+
label: "Age >= 18?",
|
|
38
|
+
condition: "age >= 18"
|
|
39
|
+
)
|
|
40
|
+
root.add_child(age_check)
|
|
41
|
+
|
|
42
|
+
# Second level under age check - check credit score
|
|
43
|
+
good_credit = DecisionAgent::Dmn::TreeNode.new(
|
|
44
|
+
id: "good_credit",
|
|
45
|
+
label: "Credit Score >= 650?",
|
|
46
|
+
condition: "credit_score >= 650"
|
|
47
|
+
)
|
|
48
|
+
age_check.add_child(good_credit)
|
|
49
|
+
|
|
50
|
+
# Leaf nodes - decisions
|
|
51
|
+
approved = DecisionAgent::Dmn::TreeNode.new(
|
|
52
|
+
id: "approved",
|
|
53
|
+
label: "Approved",
|
|
54
|
+
decision: "Approved"
|
|
55
|
+
)
|
|
56
|
+
good_credit.add_child(approved)
|
|
57
|
+
|
|
58
|
+
rejected_credit = DecisionAgent::Dmn::TreeNode.new(
|
|
59
|
+
id: "rejected_credit",
|
|
60
|
+
label: "Rejected - Poor Credit",
|
|
61
|
+
decision: "Rejected - Poor Credit"
|
|
62
|
+
)
|
|
63
|
+
good_credit.add_child(rejected_credit)
|
|
64
|
+
|
|
65
|
+
# Rejected for age
|
|
66
|
+
rejected_age = DecisionAgent::Dmn::TreeNode.new(
|
|
67
|
+
id: "rejected_age",
|
|
68
|
+
label: "Rejected - Too Young",
|
|
69
|
+
decision: "Rejected - Too Young"
|
|
70
|
+
)
|
|
71
|
+
root.add_child(rejected_age)
|
|
72
|
+
|
|
73
|
+
tree
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it "evaluates tree with context matching approved path" do
|
|
77
|
+
context = { age: 25, credit_score: 700 }
|
|
78
|
+
result = tree.evaluate(context)
|
|
79
|
+
expect(result).to eq("Approved")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it "evaluates tree with poor credit" do
|
|
83
|
+
context = { age: 25, credit_score: 600 }
|
|
84
|
+
result = tree.evaluate(context)
|
|
85
|
+
expect(result).to eq("Rejected - Poor Credit")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it "evaluates tree with age too young" do
|
|
89
|
+
context = { age: 16, credit_score: 700 }
|
|
90
|
+
result = tree.evaluate(context)
|
|
91
|
+
expect(result).to eq("Rejected - Too Young")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it "returns nil when no path matches" do
|
|
95
|
+
context = {}
|
|
96
|
+
result = tree.evaluate(context)
|
|
97
|
+
expect(result).to be_nil
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
describe "tree serialization" do
|
|
102
|
+
let(:tree_hash) do
|
|
103
|
+
{
|
|
104
|
+
id: "test_tree",
|
|
105
|
+
name: "Test Tree",
|
|
106
|
+
root: {
|
|
107
|
+
id: "root",
|
|
108
|
+
label: "Root",
|
|
109
|
+
condition: nil,
|
|
110
|
+
decision: nil,
|
|
111
|
+
children: [
|
|
112
|
+
{
|
|
113
|
+
id: "node1",
|
|
114
|
+
label: "Node 1",
|
|
115
|
+
condition: "x > 5",
|
|
116
|
+
decision: nil,
|
|
117
|
+
children: [
|
|
118
|
+
{
|
|
119
|
+
id: "leaf1",
|
|
120
|
+
label: "Leaf 1",
|
|
121
|
+
condition: nil,
|
|
122
|
+
decision: "Result A",
|
|
123
|
+
children: []
|
|
124
|
+
}
|
|
125
|
+
]
|
|
126
|
+
}
|
|
127
|
+
]
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it "converts tree to hash" do
|
|
133
|
+
tree = described_class.new(id: "tree1", name: "Tree 1")
|
|
134
|
+
node1 = DecisionAgent::Dmn::TreeNode.new(id: "node1", condition: "x > 5")
|
|
135
|
+
tree.root.add_child(node1)
|
|
136
|
+
|
|
137
|
+
hash = tree.to_h
|
|
138
|
+
expect(hash[:id]).to eq("tree1")
|
|
139
|
+
expect(hash[:name]).to eq("Tree 1")
|
|
140
|
+
expect(hash[:root][:children].length).to eq(1)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
it "builds tree from hash" do
|
|
144
|
+
tree = described_class.from_hash(tree_hash)
|
|
145
|
+
|
|
146
|
+
expect(tree.id).to eq("test_tree")
|
|
147
|
+
expect(tree.name).to eq("Test Tree")
|
|
148
|
+
expect(tree.root.children.length).to eq(1)
|
|
149
|
+
expect(tree.root.children.first.id).to eq("node1")
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
describe "tree analysis" do
|
|
154
|
+
let(:tree) do
|
|
155
|
+
tree = described_class.new(id: "analysis_tree", name: "Analysis Tree")
|
|
156
|
+
|
|
157
|
+
level1 = DecisionAgent::Dmn::TreeNode.new(id: "level1")
|
|
158
|
+
tree.root.add_child(level1)
|
|
159
|
+
|
|
160
|
+
level2a = DecisionAgent::Dmn::TreeNode.new(id: "level2a")
|
|
161
|
+
level2b = DecisionAgent::Dmn::TreeNode.new(id: "level2b")
|
|
162
|
+
level1.add_child(level2a)
|
|
163
|
+
level1.add_child(level2b)
|
|
164
|
+
|
|
165
|
+
leaf1 = DecisionAgent::Dmn::TreeNode.new(id: "leaf1", decision: "Result 1")
|
|
166
|
+
leaf2 = DecisionAgent::Dmn::TreeNode.new(id: "leaf2", decision: "Result 2")
|
|
167
|
+
level2a.add_child(leaf1)
|
|
168
|
+
level2b.add_child(leaf2)
|
|
169
|
+
|
|
170
|
+
tree
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
it "collects all leaf nodes" do
|
|
174
|
+
leaves = tree.leaf_nodes
|
|
175
|
+
expect(leaves.length).to eq(2)
|
|
176
|
+
expect(leaves.map(&:id)).to include("leaf1", "leaf2")
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
it "calculates tree depth" do
|
|
180
|
+
expect(tree.depth).to eq(3) # root -> level1 -> level2 -> leaf (depth 3)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
it "collects all paths from root to leaves" do
|
|
184
|
+
paths = tree.paths
|
|
185
|
+
expect(paths.length).to eq(2)
|
|
186
|
+
expect(paths.first.length).to be >= 3
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
describe DecisionAgent::Dmn::TreeNode do
|
|
191
|
+
it "identifies leaf nodes correctly" do
|
|
192
|
+
node = DecisionAgent::Dmn::TreeNode.new(id: "test", decision: "Decision")
|
|
193
|
+
expect(node.leaf?).to be true
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
it "identifies non-leaf nodes correctly" do
|
|
197
|
+
node = DecisionAgent::Dmn::TreeNode.new(id: "test")
|
|
198
|
+
child = DecisionAgent::Dmn::TreeNode.new(id: "child")
|
|
199
|
+
node.add_child(child)
|
|
200
|
+
expect(node.leaf?).to be false
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
require "decision_agent/dmn/errors"
|
|
3
|
+
|
|
4
|
+
RSpec.describe "FEEL Errors" do
|
|
5
|
+
describe DecisionAgent::Dmn::FeelParseError do
|
|
6
|
+
it "creates error with message only" do
|
|
7
|
+
error = DecisionAgent::Dmn::FeelParseError.new("Parse failed")
|
|
8
|
+
expect(error.message).to eq("Parse failed")
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
describe DecisionAgent::Dmn::FeelEvaluationError do
|
|
13
|
+
it "creates error with message only" do
|
|
14
|
+
error = DecisionAgent::Dmn::FeelEvaluationError.new("Evaluation failed")
|
|
15
|
+
expect(error.message).to eq("Evaluation failed")
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
require "spec_helper"
|
|
2
|
+
require "decision_agent/dmn/feel/functions"
|
|
3
|
+
|
|
4
|
+
RSpec.describe DecisionAgent::Dmn::Feel::Functions do
|
|
5
|
+
describe "String Functions" do
|
|
6
|
+
describe "substring" do
|
|
7
|
+
it "extracts substring with start and length" do
|
|
8
|
+
result = described_class.execute("substring", ["hello world", 1, 5])
|
|
9
|
+
expect(result).to eq("hello")
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it "extracts substring from start to end" do
|
|
13
|
+
result = described_class.execute("substring", ["hello world", 7])
|
|
14
|
+
expect(result).to eq("world")
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it "handles 1-based indexing" do
|
|
18
|
+
result = described_class.execute("substring", ["abc", 2, 1])
|
|
19
|
+
expect(result).to eq("b")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
describe "string length" do
|
|
24
|
+
it "returns length of string" do
|
|
25
|
+
result = described_class.execute("string length", ["hello"])
|
|
26
|
+
expect(result).to eq(5)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it "returns 0 for empty string" do
|
|
30
|
+
result = described_class.execute("string length", [""])
|
|
31
|
+
expect(result).to eq(0)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
describe "upper case" do
|
|
36
|
+
it "converts to uppercase" do
|
|
37
|
+
result = described_class.execute("upper case", ["hello"])
|
|
38
|
+
expect(result).to eq("HELLO")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
describe "lower case" do
|
|
43
|
+
it "converts to lowercase" do
|
|
44
|
+
result = described_class.execute("lower case", ["HELLO"])
|
|
45
|
+
expect(result).to eq("hello")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
describe "contains" do
|
|
50
|
+
it "returns true when substring is found" do
|
|
51
|
+
result = described_class.execute("contains", ["hello world", "world"])
|
|
52
|
+
expect(result).to be true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it "returns false when substring is not found" do
|
|
56
|
+
result = described_class.execute("contains", ["hello world", "xyz"])
|
|
57
|
+
expect(result).to be false
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
describe "starts with" do
|
|
62
|
+
it "returns true when string starts with prefix" do
|
|
63
|
+
result = described_class.execute("starts with", ["hello world", "hello"])
|
|
64
|
+
expect(result).to be true
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it "returns false when string does not start with prefix" do
|
|
68
|
+
result = described_class.execute("starts with", ["hello world", "world"])
|
|
69
|
+
expect(result).to be false
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
describe "ends with" do
|
|
74
|
+
it "returns true when string ends with suffix" do
|
|
75
|
+
result = described_class.execute("ends with", ["hello world", "world"])
|
|
76
|
+
expect(result).to be true
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it "returns false when string does not end with suffix" do
|
|
80
|
+
result = described_class.execute("ends with", ["hello world", "hello"])
|
|
81
|
+
expect(result).to be false
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
describe "substring before" do
|
|
86
|
+
it "returns substring before match" do
|
|
87
|
+
result = described_class.execute("substring before", ["hello world", " "])
|
|
88
|
+
expect(result).to eq("hello")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it "returns empty string when match not found" do
|
|
92
|
+
result = described_class.execute("substring before", %w[hello x])
|
|
93
|
+
expect(result).to eq("")
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
describe "substring after" do
|
|
98
|
+
it "returns substring after match" do
|
|
99
|
+
result = described_class.execute("substring after", ["hello world", " "])
|
|
100
|
+
expect(result).to eq("world")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it "returns empty string when match not found" do
|
|
104
|
+
result = described_class.execute("substring after", %w[hello x])
|
|
105
|
+
expect(result).to eq("")
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
describe "replace" do
|
|
110
|
+
it "replaces all occurrences" do
|
|
111
|
+
result = described_class.execute("replace", ["hello world", "l", "L"])
|
|
112
|
+
expect(result).to eq("heLLo worLd")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
describe "Numeric Functions" do
|
|
118
|
+
describe "abs" do
|
|
119
|
+
it "returns absolute value of positive number" do
|
|
120
|
+
result = described_class.execute("abs", [5])
|
|
121
|
+
expect(result).to eq(5.0)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it "returns absolute value of negative number" do
|
|
125
|
+
result = described_class.execute("abs", [-5])
|
|
126
|
+
expect(result).to eq(5.0)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
describe "floor" do
|
|
131
|
+
it "rounds down to integer" do
|
|
132
|
+
result = described_class.execute("floor", [3.7])
|
|
133
|
+
expect(result).to eq(3)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
it "handles negative numbers" do
|
|
137
|
+
result = described_class.execute("floor", [-3.2])
|
|
138
|
+
expect(result).to eq(-4)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
describe "ceiling" do
|
|
143
|
+
it "rounds up to integer" do
|
|
144
|
+
result = described_class.execute("ceiling", [3.2])
|
|
145
|
+
expect(result).to eq(4)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it "handles negative numbers" do
|
|
149
|
+
result = described_class.execute("ceiling", [-3.7])
|
|
150
|
+
expect(result).to eq(-3)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
describe "round" do
|
|
155
|
+
it "rounds to nearest integer" do
|
|
156
|
+
result = described_class.execute("round", [3.7])
|
|
157
|
+
expect(result).to eq(4)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
it "rounds to specified precision" do
|
|
161
|
+
result = described_class.execute("round", [3.14159, 2])
|
|
162
|
+
expect(result).to be_within(0.001).of(3.14)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
describe "sqrt" do
|
|
167
|
+
it "calculates square root" do
|
|
168
|
+
result = described_class.execute("sqrt", [16])
|
|
169
|
+
expect(result).to eq(4.0)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
describe "modulo" do
|
|
174
|
+
it "calculates remainder" do
|
|
175
|
+
result = described_class.execute("modulo", [10, 3])
|
|
176
|
+
expect(result).to eq(1.0)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
describe "odd" do
|
|
181
|
+
it "returns true for odd numbers" do
|
|
182
|
+
result = described_class.execute("odd", [5])
|
|
183
|
+
expect(result).to be true
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
it "returns false for even numbers" do
|
|
187
|
+
result = described_class.execute("odd", [4])
|
|
188
|
+
expect(result).to be false
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
describe "even" do
|
|
193
|
+
it "returns true for even numbers" do
|
|
194
|
+
result = described_class.execute("even", [4])
|
|
195
|
+
expect(result).to be true
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
it "returns false for odd numbers" do
|
|
199
|
+
result = described_class.execute("even", [5])
|
|
200
|
+
expect(result).to be false
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
describe "List Functions" do
|
|
206
|
+
describe "count" do
|
|
207
|
+
it "returns length of list" do
|
|
208
|
+
result = described_class.execute("count", [[1, 2, 3, 4, 5]])
|
|
209
|
+
expect(result).to eq(5)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
it "returns 0 for empty list" do
|
|
213
|
+
result = described_class.execute("count", [[]])
|
|
214
|
+
expect(result).to eq(0)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
it "returns 0 for non-array" do
|
|
218
|
+
result = described_class.execute("count", [42])
|
|
219
|
+
expect(result).to eq(0)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
describe "sum" do
|
|
224
|
+
it "calculates sum of list" do
|
|
225
|
+
result = described_class.execute("sum", [[1, 2, 3, 4, 5]])
|
|
226
|
+
expect(result).to eq(15.0)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
it "returns 0 for empty list" do
|
|
230
|
+
result = described_class.execute("sum", [[]])
|
|
231
|
+
expect(result).to eq(0)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
describe "mean" do
|
|
236
|
+
it "calculates average of list" do
|
|
237
|
+
result = described_class.execute("mean", [[1, 2, 3, 4, 5]])
|
|
238
|
+
expect(result).to eq(3.0)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
it "returns 0 for empty list" do
|
|
242
|
+
result = described_class.execute("mean", [[]])
|
|
243
|
+
expect(result).to eq(0)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
describe "min" do
|
|
248
|
+
it "returns minimum value from list" do
|
|
249
|
+
result = described_class.execute("min", [[5, 2, 8, 1, 9]])
|
|
250
|
+
expect(result).to eq(1.0)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
it "returns minimum from multiple arguments" do
|
|
254
|
+
result = described_class.execute("min", [5, 2, 8, 1, 9])
|
|
255
|
+
expect(result).to eq(1.0)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
it "returns nil for empty list" do
|
|
259
|
+
result = described_class.execute("min", [[]])
|
|
260
|
+
expect(result).to be_nil
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
describe "max" do
|
|
265
|
+
it "returns maximum value from list" do
|
|
266
|
+
result = described_class.execute("max", [[5, 2, 8, 1, 9]])
|
|
267
|
+
expect(result).to eq(9.0)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
it "returns maximum from multiple arguments" do
|
|
271
|
+
result = described_class.execute("max", [5, 2, 8, 1, 9])
|
|
272
|
+
expect(result).to eq(9.0)
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
describe "append" do
|
|
277
|
+
it "appends items to list" do
|
|
278
|
+
result = described_class.execute("append", [[1, 2], 3, 4])
|
|
279
|
+
expect(result).to eq([1, 2, 3, 4])
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
describe "reverse" do
|
|
284
|
+
it "reverses list" do
|
|
285
|
+
result = described_class.execute("reverse", [[1, 2, 3, 4, 5]])
|
|
286
|
+
expect(result).to eq([5, 4, 3, 2, 1])
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
describe "index of" do
|
|
291
|
+
it "returns 1-based index of element" do
|
|
292
|
+
result = described_class.execute("index of", [[10, 20, 30], 20])
|
|
293
|
+
expect(result).to eq(2)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
it "returns -1 when element not found" do
|
|
297
|
+
result = described_class.execute("index of", [[10, 20, 30], 40])
|
|
298
|
+
expect(result).to eq(-1)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
describe "distinct values" do
|
|
303
|
+
it "removes duplicates" do
|
|
304
|
+
result = described_class.execute("distinct values", [[1, 2, 2, 3, 3, 3]])
|
|
305
|
+
expect(result).to eq([1, 2, 3])
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
describe "Boolean Functions" do
|
|
311
|
+
describe "not" do
|
|
312
|
+
it "negates true" do
|
|
313
|
+
result = described_class.execute("not", [true])
|
|
314
|
+
expect(result).to be false
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
it "negates false" do
|
|
318
|
+
result = described_class.execute("not", [false])
|
|
319
|
+
expect(result).to be true
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
describe "all" do
|
|
324
|
+
it "returns true when all items are true" do
|
|
325
|
+
result = described_class.execute("all", [[true, true, true]])
|
|
326
|
+
expect(result).to be true
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
it "returns false when any item is false" do
|
|
330
|
+
result = described_class.execute("all", [[true, false, true]])
|
|
331
|
+
expect(result).to be false
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
describe "any" do
|
|
336
|
+
it "returns true when any item is true" do
|
|
337
|
+
result = described_class.execute("any", [[false, true, false]])
|
|
338
|
+
expect(result).to be true
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
it "returns false when all items are false" do
|
|
342
|
+
result = described_class.execute("any", [[false, false, false]])
|
|
343
|
+
expect(result).to be false
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
describe "Date/Time Functions" do
|
|
349
|
+
describe "date" do
|
|
350
|
+
it "parses ISO 8601 date string" do
|
|
351
|
+
result = described_class.execute("date", ["2024-01-15T10:30:00Z"])
|
|
352
|
+
expect(result).to be_a(DecisionAgent::Dmn::Feel::Types::Date)
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
describe "time" do
|
|
357
|
+
it "parses ISO 8601 time string" do
|
|
358
|
+
result = described_class.execute("time", ["2024-01-15T10:30:00Z"])
|
|
359
|
+
expect(result).to be_a(DecisionAgent::Dmn::Feel::Types::Time)
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
describe "duration" do
|
|
364
|
+
it "parses ISO 8601 duration" do
|
|
365
|
+
result = described_class.execute("duration", ["P1Y2M3DT4H5M6S"])
|
|
366
|
+
expect(result).to be_a(DecisionAgent::Dmn::Feel::Types::Duration)
|
|
367
|
+
expect(result.years).to eq(1)
|
|
368
|
+
expect(result.months).to eq(2)
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
describe "Function Registry" do
|
|
374
|
+
it "lists all registered functions" do
|
|
375
|
+
functions = described_class.list
|
|
376
|
+
expect(functions).to include("substring")
|
|
377
|
+
expect(functions).to include("sum")
|
|
378
|
+
expect(functions).to include("abs")
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
it "gets function by name" do
|
|
382
|
+
func = described_class.get("substring")
|
|
383
|
+
expect(func).not_to be_nil
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
it "raises error for unknown function" do
|
|
387
|
+
expect do
|
|
388
|
+
described_class.execute("unknown_func", [])
|
|
389
|
+
end.to raise_error(DecisionAgent::Dmn::FeelFunctionError, /Unknown function/)
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
describe "Argument Validation" do
|
|
394
|
+
it "raises error for wrong argument count" do
|
|
395
|
+
expect do
|
|
396
|
+
described_class.execute("substring", ["hello"])
|
|
397
|
+
end.to raise_error(DecisionAgent::Dmn::FeelFunctionError, /Wrong number of arguments/)
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
end
|