decision_agent 0.3.0 → 1.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 +4 -4
- data/README.md +272 -7
- data/lib/decision_agent/agent.rb +72 -1
- data/lib/decision_agent/context.rb +1 -0
- data/lib/decision_agent/data_enrichment/cache/memory_adapter.rb +86 -0
- data/lib/decision_agent/data_enrichment/cache_adapter.rb +49 -0
- data/lib/decision_agent/data_enrichment/circuit_breaker.rb +135 -0
- data/lib/decision_agent/data_enrichment/client.rb +220 -0
- data/lib/decision_agent/data_enrichment/config.rb +78 -0
- data/lib/decision_agent/data_enrichment/errors.rb +36 -0
- data/lib/decision_agent/decision.rb +102 -2
- data/lib/decision_agent/dmn/feel/evaluator.rb +28 -6
- data/lib/decision_agent/dsl/condition_evaluator.rb +982 -839
- data/lib/decision_agent/dsl/schema_validator.rb +51 -13
- data/lib/decision_agent/evaluators/dmn_evaluator.rb +106 -19
- data/lib/decision_agent/evaluators/json_rule_evaluator.rb +69 -9
- data/lib/decision_agent/explainability/condition_trace.rb +83 -0
- data/lib/decision_agent/explainability/explainability_result.rb +52 -0
- data/lib/decision_agent/explainability/rule_trace.rb +39 -0
- data/lib/decision_agent/explainability/trace_collector.rb +24 -0
- data/lib/decision_agent/monitoring/alert_manager.rb +5 -1
- data/lib/decision_agent/simulation/errors.rb +18 -0
- data/lib/decision_agent/simulation/impact_analyzer.rb +498 -0
- data/lib/decision_agent/simulation/monte_carlo_simulator.rb +635 -0
- data/lib/decision_agent/simulation/replay_engine.rb +486 -0
- data/lib/decision_agent/simulation/scenario_engine.rb +318 -0
- data/lib/decision_agent/simulation/scenario_library.rb +163 -0
- data/lib/decision_agent/simulation/shadow_test_engine.rb +287 -0
- data/lib/decision_agent/simulation/what_if_analyzer.rb +1002 -0
- data/lib/decision_agent/simulation.rb +17 -0
- data/lib/decision_agent/version.rb +1 -1
- data/lib/decision_agent/versioning/activerecord_adapter.rb +23 -8
- data/lib/decision_agent/web/public/app.js +119 -0
- data/lib/decision_agent/web/public/index.html +49 -0
- data/lib/decision_agent/web/public/simulation.html +130 -0
- data/lib/decision_agent/web/public/simulation_impact.html +478 -0
- data/lib/decision_agent/web/public/simulation_replay.html +551 -0
- data/lib/decision_agent/web/public/simulation_shadow.html +546 -0
- data/lib/decision_agent/web/public/simulation_whatif.html +532 -0
- data/lib/decision_agent/web/public/styles.css +65 -0
- data/lib/decision_agent/web/server.rb +594 -23
- data/lib/decision_agent.rb +60 -2
- metadata +53 -73
- data/spec/ab_testing/ab_test_assignment_spec.rb +0 -253
- data/spec/ab_testing/ab_test_manager_spec.rb +0 -612
- data/spec/ab_testing/ab_test_spec.rb +0 -270
- data/spec/ab_testing/ab_testing_agent_spec.rb +0 -655
- data/spec/ab_testing/storage/adapter_spec.rb +0 -64
- data/spec/ab_testing/storage/memory_adapter_spec.rb +0 -485
- data/spec/activerecord_thread_safety_spec.rb +0 -553
- data/spec/advanced_operators_spec.rb +0 -3150
- data/spec/agent_spec.rb +0 -289
- data/spec/api_contract_spec.rb +0 -430
- data/spec/audit_adapters_spec.rb +0 -92
- data/spec/auth/access_audit_logger_spec.rb +0 -394
- data/spec/auth/authenticator_spec.rb +0 -112
- data/spec/auth/password_reset_spec.rb +0 -294
- data/spec/auth/permission_checker_spec.rb +0 -207
- data/spec/auth/permission_spec.rb +0 -73
- data/spec/auth/rbac_adapter_spec.rb +0 -778
- data/spec/auth/rbac_config_spec.rb +0 -82
- data/spec/auth/role_spec.rb +0 -51
- data/spec/auth/session_manager_spec.rb +0 -172
- data/spec/auth/session_spec.rb +0 -112
- data/spec/auth/user_spec.rb +0 -130
- data/spec/comprehensive_edge_cases_spec.rb +0 -1777
- data/spec/context_spec.rb +0 -127
- data/spec/decision_agent_spec.rb +0 -96
- data/spec/decision_spec.rb +0 -423
- data/spec/dmn/decision_graph_spec.rb +0 -282
- data/spec/dmn/decision_tree_spec.rb +0 -203
- data/spec/dmn/feel/errors_spec.rb +0 -18
- data/spec/dmn/feel/functions_spec.rb +0 -400
- data/spec/dmn/feel/simple_parser_spec.rb +0 -274
- data/spec/dmn/feel/types_spec.rb +0 -176
- data/spec/dmn/feel_parser_spec.rb +0 -489
- data/spec/dmn/hit_policy_spec.rb +0 -202
- data/spec/dmn/integration_spec.rb +0 -226
- data/spec/dsl/condition_evaluator_spec.rb +0 -774
- data/spec/dsl_validation_spec.rb +0 -648
- data/spec/edge_cases_spec.rb +0 -353
- data/spec/evaluation_spec.rb +0 -364
- data/spec/evaluation_validator_spec.rb +0 -165
- data/spec/examples/feedback_aware_evaluator_spec.rb +0 -460
- data/spec/examples.txt +0 -1909
- data/spec/fixtures/dmn/complex_decision.dmn +0 -81
- data/spec/fixtures/dmn/invalid_structure.dmn +0 -31
- data/spec/fixtures/dmn/simple_decision.dmn +0 -40
- data/spec/issue_verification_spec.rb +0 -759
- data/spec/json_rule_evaluator_spec.rb +0 -587
- data/spec/monitoring/alert_manager_spec.rb +0 -378
- data/spec/monitoring/metrics_collector_spec.rb +0 -501
- data/spec/monitoring/monitored_agent_spec.rb +0 -225
- data/spec/monitoring/prometheus_exporter_spec.rb +0 -242
- data/spec/monitoring/storage/activerecord_adapter_spec.rb +0 -498
- data/spec/monitoring/storage/base_adapter_spec.rb +0 -61
- data/spec/monitoring/storage/memory_adapter_spec.rb +0 -247
- data/spec/performance_optimizations_spec.rb +0 -493
- data/spec/replay_edge_cases_spec.rb +0 -699
- data/spec/replay_spec.rb +0 -210
- data/spec/rfc8785_canonicalization_spec.rb +0 -215
- data/spec/scoring_spec.rb +0 -225
- data/spec/spec_helper.rb +0 -60
- data/spec/testing/batch_test_importer_spec.rb +0 -693
- data/spec/testing/batch_test_runner_spec.rb +0 -307
- data/spec/testing/test_coverage_analyzer_spec.rb +0 -292
- data/spec/testing/test_result_comparator_spec.rb +0 -392
- data/spec/testing/test_scenario_spec.rb +0 -113
- data/spec/thread_safety_spec.rb +0 -490
- data/spec/thread_safety_spec.rb.broken +0 -878
- data/spec/versioning/adapter_spec.rb +0 -156
- data/spec/versioning_spec.rb +0 -1030
- data/spec/web/middleware/auth_middleware_spec.rb +0 -133
- data/spec/web/middleware/permission_middleware_spec.rb +0 -247
- data/spec/web_ui_rack_spec.rb +0 -2134
|
@@ -1,774 +0,0 @@
|
|
|
1
|
-
require "spec_helper"
|
|
2
|
-
|
|
3
|
-
RSpec.describe DecisionAgent::Dsl::ConditionEvaluator do
|
|
4
|
-
let(:context) { DecisionAgent::Context.new({ status: "active", age: 30, score: 85 }) }
|
|
5
|
-
|
|
6
|
-
describe ".evaluate" do
|
|
7
|
-
context "with invalid input" do
|
|
8
|
-
it "returns false for non-hash condition" do
|
|
9
|
-
result = described_class.evaluate("not a hash", context)
|
|
10
|
-
expect(result).to be false
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
it "returns false for nil condition" do
|
|
14
|
-
result = described_class.evaluate(nil, context)
|
|
15
|
-
expect(result).to be false
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
it "returns false for condition without field, all, or any" do
|
|
19
|
-
result = described_class.evaluate({ invalid: "key" }, context)
|
|
20
|
-
expect(result).to be false
|
|
21
|
-
end
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
context "with 'all' condition" do
|
|
25
|
-
it "evaluates all conditions" do
|
|
26
|
-
condition = {
|
|
27
|
-
"all" => [
|
|
28
|
-
{ "field" => "status", "op" => "eq", "value" => "active" },
|
|
29
|
-
{ "field" => "age", "op" => "gt", "value" => 18 }
|
|
30
|
-
]
|
|
31
|
-
}
|
|
32
|
-
result = described_class.evaluate(condition, context)
|
|
33
|
-
expect(result).to be true
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
it "returns false if any condition fails" do
|
|
37
|
-
condition = {
|
|
38
|
-
"all" => [
|
|
39
|
-
{ "field" => "status", "op" => "eq", "value" => "active" },
|
|
40
|
-
{ "field" => "age", "op" => "gt", "value" => 100 }
|
|
41
|
-
]
|
|
42
|
-
}
|
|
43
|
-
result = described_class.evaluate(condition, context)
|
|
44
|
-
expect(result).to be false
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
context "with 'any' condition" do
|
|
49
|
-
it "evaluates any condition" do
|
|
50
|
-
condition = {
|
|
51
|
-
"any" => [
|
|
52
|
-
{ "field" => "status", "op" => "eq", "value" => "inactive" },
|
|
53
|
-
{ "field" => "age", "op" => "gt", "value" => 18 }
|
|
54
|
-
]
|
|
55
|
-
}
|
|
56
|
-
result = described_class.evaluate(condition, context)
|
|
57
|
-
expect(result).to be true
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
it "returns false if all conditions fail" do
|
|
61
|
-
condition = {
|
|
62
|
-
"any" => [
|
|
63
|
-
{ "field" => "status", "op" => "eq", "value" => "inactive" },
|
|
64
|
-
{ "field" => "age", "op" => "gt", "value" => 100 }
|
|
65
|
-
]
|
|
66
|
-
}
|
|
67
|
-
result = described_class.evaluate(condition, context)
|
|
68
|
-
expect(result).to be false
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
context "with field condition" do
|
|
73
|
-
it "evaluates field condition" do
|
|
74
|
-
condition = { "field" => "status", "op" => "eq", "value" => "active" }
|
|
75
|
-
result = described_class.evaluate(condition, context)
|
|
76
|
-
expect(result).to be true
|
|
77
|
-
end
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
describe ".evaluate_all" do
|
|
82
|
-
it "returns true for empty array" do
|
|
83
|
-
result = described_class.evaluate_all([], context)
|
|
84
|
-
expect(result).to be true
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
it "returns false for non-array input" do
|
|
88
|
-
result = described_class.evaluate_all("not an array", context)
|
|
89
|
-
expect(result).to be false
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
it "returns true when all conditions are true" do
|
|
93
|
-
conditions = [
|
|
94
|
-
{ "field" => "status", "op" => "eq", "value" => "active" },
|
|
95
|
-
{ "field" => "age", "op" => "gt", "value" => 18 }
|
|
96
|
-
]
|
|
97
|
-
result = described_class.evaluate_all(conditions, context)
|
|
98
|
-
expect(result).to be true
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
it "returns false when any condition is false" do
|
|
102
|
-
conditions = [
|
|
103
|
-
{ "field" => "status", "op" => "eq", "value" => "active" },
|
|
104
|
-
{ "field" => "age", "op" => "gt", "value" => 100 }
|
|
105
|
-
]
|
|
106
|
-
result = described_class.evaluate_all(conditions, context)
|
|
107
|
-
expect(result).to be false
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
describe ".evaluate_any" do
|
|
112
|
-
it "returns false for empty array" do
|
|
113
|
-
result = described_class.evaluate_any([], context)
|
|
114
|
-
expect(result).to be false
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
it "returns false for non-array input" do
|
|
118
|
-
result = described_class.evaluate_any("not an array", context)
|
|
119
|
-
expect(result).to be false
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
it "returns true when at least one condition is true" do
|
|
123
|
-
conditions = [
|
|
124
|
-
{ "field" => "status", "op" => "eq", "value" => "inactive" },
|
|
125
|
-
{ "field" => "age", "op" => "gt", "value" => 18 }
|
|
126
|
-
]
|
|
127
|
-
result = described_class.evaluate_any(conditions, context)
|
|
128
|
-
expect(result).to be true
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
it "returns false when all conditions are false" do
|
|
132
|
-
conditions = [
|
|
133
|
-
{ "field" => "status", "op" => "eq", "value" => "inactive" },
|
|
134
|
-
{ "field" => "age", "op" => "gt", "value" => 100 }
|
|
135
|
-
]
|
|
136
|
-
result = described_class.evaluate_any(conditions, context)
|
|
137
|
-
expect(result).to be false
|
|
138
|
-
end
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
describe ".evaluate_field_condition" do
|
|
142
|
-
describe "equality operators" do
|
|
143
|
-
it "handles eq operator" do
|
|
144
|
-
condition = { "field" => "status", "op" => "eq", "value" => "active" }
|
|
145
|
-
result = described_class.evaluate_field_condition(condition, context)
|
|
146
|
-
expect(result).to be true
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
it "handles neq operator" do
|
|
150
|
-
condition = { "field" => "status", "op" => "neq", "value" => "inactive" }
|
|
151
|
-
result = described_class.evaluate_field_condition(condition, context)
|
|
152
|
-
expect(result).to be true
|
|
153
|
-
end
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
describe "comparison operators" do
|
|
157
|
-
it "handles gt operator" do
|
|
158
|
-
condition = { "field" => "age", "op" => "gt", "value" => 18 }
|
|
159
|
-
result = described_class.evaluate_field_condition(condition, context)
|
|
160
|
-
expect(result).to be true
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
it "handles gte operator" do
|
|
164
|
-
condition = { "field" => "age", "op" => "gte", "value" => 30 }
|
|
165
|
-
result = described_class.evaluate_field_condition(condition, context)
|
|
166
|
-
expect(result).to be true
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
it "handles lt operator" do
|
|
170
|
-
condition = { "field" => "age", "op" => "lt", "value" => 40 }
|
|
171
|
-
result = described_class.evaluate_field_condition(condition, context)
|
|
172
|
-
expect(result).to be true
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
it "handles lte operator" do
|
|
176
|
-
condition = { "field" => "age", "op" => "lte", "value" => 30 }
|
|
177
|
-
result = described_class.evaluate_field_condition(condition, context)
|
|
178
|
-
expect(result).to be true
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
it "returns false for incompatible types in comparison" do
|
|
182
|
-
condition = { "field" => "status", "op" => "gt", "value" => 10 }
|
|
183
|
-
result = described_class.evaluate_field_condition(condition, context)
|
|
184
|
-
expect(result).to be false
|
|
185
|
-
end
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
describe "membership operators" do
|
|
189
|
-
it "handles in operator" do
|
|
190
|
-
condition = { "field" => "status", "op" => "in", "value" => %w[active inactive] }
|
|
191
|
-
result = described_class.evaluate_field_condition(condition, context)
|
|
192
|
-
expect(result).to be true
|
|
193
|
-
end
|
|
194
|
-
|
|
195
|
-
it "handles in operator with non-array value" do
|
|
196
|
-
condition = { "field" => "status", "op" => "in", "value" => "active" }
|
|
197
|
-
result = described_class.evaluate_field_condition(condition, context)
|
|
198
|
-
expect(result).to be true
|
|
199
|
-
end
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
describe "presence operators" do
|
|
203
|
-
it "handles present operator with non-empty value" do
|
|
204
|
-
condition = { "field" => "status", "op" => "present" }
|
|
205
|
-
result = described_class.evaluate_field_condition(condition, context)
|
|
206
|
-
expect(result).to be true
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
it "handles present operator with nil" do
|
|
210
|
-
ctx = DecisionAgent::Context.new({ status: nil })
|
|
211
|
-
condition = { "field" => "status", "op" => "present" }
|
|
212
|
-
result = described_class.evaluate_field_condition(condition, ctx)
|
|
213
|
-
expect(result).to be false
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
it "handles present operator with empty string" do
|
|
217
|
-
ctx = DecisionAgent::Context.new({ status: "" })
|
|
218
|
-
condition = { "field" => "status", "op" => "present" }
|
|
219
|
-
result = described_class.evaluate_field_condition(condition, ctx)
|
|
220
|
-
expect(result).to be false
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
it "handles present operator with empty array" do
|
|
224
|
-
ctx = DecisionAgent::Context.new({ items: [] })
|
|
225
|
-
condition = { "field" => "items", "op" => "present" }
|
|
226
|
-
result = described_class.evaluate_field_condition(condition, ctx)
|
|
227
|
-
expect(result).to be false
|
|
228
|
-
end
|
|
229
|
-
|
|
230
|
-
it "handles present operator with zero" do
|
|
231
|
-
ctx = DecisionAgent::Context.new({ count: 0 })
|
|
232
|
-
condition = { "field" => "count", "op" => "present" }
|
|
233
|
-
result = described_class.evaluate_field_condition(condition, ctx)
|
|
234
|
-
expect(result).to be true
|
|
235
|
-
end
|
|
236
|
-
|
|
237
|
-
it "handles present operator with false boolean" do
|
|
238
|
-
ctx = DecisionAgent::Context.new({ active: false })
|
|
239
|
-
condition = { "field" => "active", "op" => "present" }
|
|
240
|
-
result = described_class.evaluate_field_condition(condition, ctx)
|
|
241
|
-
expect(result).to be true
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
it "handles blank operator with nil" do
|
|
245
|
-
ctx = DecisionAgent::Context.new({ status: nil })
|
|
246
|
-
condition = { "field" => "status", "op" => "blank" }
|
|
247
|
-
result = described_class.evaluate_field_condition(condition, ctx)
|
|
248
|
-
expect(result).to be true
|
|
249
|
-
end
|
|
250
|
-
|
|
251
|
-
it "handles blank operator with empty string" do
|
|
252
|
-
ctx = DecisionAgent::Context.new({ status: "" })
|
|
253
|
-
condition = { "field" => "status", "op" => "blank" }
|
|
254
|
-
result = described_class.evaluate_field_condition(condition, ctx)
|
|
255
|
-
expect(result).to be true
|
|
256
|
-
end
|
|
257
|
-
|
|
258
|
-
it "handles blank operator with zero" do
|
|
259
|
-
ctx = DecisionAgent::Context.new({ count: 0 })
|
|
260
|
-
condition = { "field" => "count", "op" => "blank" }
|
|
261
|
-
result = described_class.evaluate_field_condition(condition, ctx)
|
|
262
|
-
expect(result).to be false
|
|
263
|
-
end
|
|
264
|
-
|
|
265
|
-
it "handles blank operator with false boolean" do
|
|
266
|
-
ctx = DecisionAgent::Context.new({ active: false })
|
|
267
|
-
condition = { "field" => "active", "op" => "blank" }
|
|
268
|
-
result = described_class.evaluate_field_condition(condition, ctx)
|
|
269
|
-
expect(result).to be false
|
|
270
|
-
end
|
|
271
|
-
end
|
|
272
|
-
|
|
273
|
-
describe "string operators" do
|
|
274
|
-
it "handles contains operator" do
|
|
275
|
-
ctx = DecisionAgent::Context.new({ message: "Hello world" })
|
|
276
|
-
condition = { "field" => "message", "op" => "contains", "value" => "world" }
|
|
277
|
-
result = described_class.evaluate_field_condition(condition, ctx)
|
|
278
|
-
expect(result).to be true
|
|
279
|
-
end
|
|
280
|
-
|
|
281
|
-
it "handles starts_with operator" do
|
|
282
|
-
ctx = DecisionAgent::Context.new({ code: "ERR_404" })
|
|
283
|
-
condition = { "field" => "code", "op" => "starts_with", "value" => "ERR" }
|
|
284
|
-
result = described_class.evaluate_field_condition(condition, ctx)
|
|
285
|
-
expect(result).to be true
|
|
286
|
-
end
|
|
287
|
-
|
|
288
|
-
it "handles ends_with operator" do
|
|
289
|
-
ctx = DecisionAgent::Context.new({ filename: "document.pdf" })
|
|
290
|
-
condition = { "field" => "filename", "op" => "ends_with", "value" => ".pdf" }
|
|
291
|
-
result = described_class.evaluate_field_condition(condition, ctx)
|
|
292
|
-
expect(result).to be true
|
|
293
|
-
end
|
|
294
|
-
|
|
295
|
-
it "handles matches operator with string regex" do
|
|
296
|
-
ctx = DecisionAgent::Context.new({ email: "user@example.com" })
|
|
297
|
-
condition = { "field" => "email", "op" => "matches", "value" => "^[a-z]+@[a-z]+\\.[a-z]+$" }
|
|
298
|
-
result = described_class.evaluate_field_condition(condition, ctx)
|
|
299
|
-
expect(result).to be true
|
|
300
|
-
end
|
|
301
|
-
|
|
302
|
-
it "handles matches operator with Regexp object" do
|
|
303
|
-
ctx = DecisionAgent::Context.new({ email: "user@example.com" })
|
|
304
|
-
condition = { "field" => "email", "op" => "matches", "value" => /^[a-z]+@[a-z]+\.[a-z]+$/ }
|
|
305
|
-
result = described_class.evaluate_field_condition(condition, ctx)
|
|
306
|
-
expect(result).to be true
|
|
307
|
-
end
|
|
308
|
-
|
|
309
|
-
it "returns false for matches with non-string value" do
|
|
310
|
-
condition = { "field" => "age", "op" => "matches", "value" => "\\d+" }
|
|
311
|
-
result = described_class.evaluate_field_condition(condition, context)
|
|
312
|
-
expect(result).to be false
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
it "handles invalid regex gracefully" do
|
|
316
|
-
ctx = DecisionAgent::Context.new({ text: "test" })
|
|
317
|
-
condition = { "field" => "text", "op" => "matches", "value" => "[invalid(" }
|
|
318
|
-
result = described_class.evaluate_field_condition(condition, ctx)
|
|
319
|
-
expect(result).to be false
|
|
320
|
-
end
|
|
321
|
-
|
|
322
|
-
it "returns false for string operators with non-string values" do
|
|
323
|
-
condition = { "field" => "age", "op" => "contains", "value" => "30" }
|
|
324
|
-
result = described_class.evaluate_field_condition(condition, context)
|
|
325
|
-
expect(result).to be false
|
|
326
|
-
end
|
|
327
|
-
end
|
|
328
|
-
|
|
329
|
-
describe "numeric operators" do
|
|
330
|
-
it "handles between operator with array" do
|
|
331
|
-
condition = { "field" => "age", "op" => "between", "value" => [18, 65] }
|
|
332
|
-
result = described_class.evaluate_field_condition(condition, context)
|
|
333
|
-
expect(result).to be true
|
|
334
|
-
end
|
|
335
|
-
|
|
336
|
-
it "handles between operator with hash" do
|
|
337
|
-
condition = { "field" => "age", "op" => "between", "value" => { "min" => 18, "max" => 65 } }
|
|
338
|
-
result = described_class.evaluate_field_condition(condition, context)
|
|
339
|
-
expect(result).to be true
|
|
340
|
-
end
|
|
341
|
-
|
|
342
|
-
it "returns false for between with non-numeric value" do
|
|
343
|
-
condition = { "field" => "status", "op" => "between", "value" => [1, 10] }
|
|
344
|
-
result = described_class.evaluate_field_condition(condition, context)
|
|
345
|
-
expect(result).to be false
|
|
346
|
-
end
|
|
347
|
-
|
|
348
|
-
it "handles modulo operator with array" do
|
|
349
|
-
condition = { "field" => "age", "op" => "modulo", "value" => [2, 0] }
|
|
350
|
-
result = described_class.evaluate_field_condition(condition, context)
|
|
351
|
-
expect(result).to be true
|
|
352
|
-
end
|
|
353
|
-
|
|
354
|
-
it "handles modulo operator with hash" do
|
|
355
|
-
condition = { "field" => "age", "op" => "modulo", "value" => { "divisor" => 2, "remainder" => 0 } }
|
|
356
|
-
result = described_class.evaluate_field_condition(condition, context)
|
|
357
|
-
expect(result).to be true
|
|
358
|
-
end
|
|
359
|
-
|
|
360
|
-
it "returns false for modulo with non-numeric value" do
|
|
361
|
-
condition = { "field" => "status", "op" => "modulo", "value" => [2, 0] }
|
|
362
|
-
result = described_class.evaluate_field_condition(condition, context)
|
|
363
|
-
expect(result).to be false
|
|
364
|
-
end
|
|
365
|
-
end
|
|
366
|
-
|
|
367
|
-
describe "date/time operators" do
|
|
368
|
-
it "handles before_date operator" do
|
|
369
|
-
ctx = DecisionAgent::Context.new({ expires_at: "2025-06-01" })
|
|
370
|
-
condition = { "field" => "expires_at", "op" => "before_date", "value" => "2025-12-31" }
|
|
371
|
-
result = described_class.evaluate_field_condition(condition, ctx)
|
|
372
|
-
expect(result).to be true
|
|
373
|
-
end
|
|
374
|
-
|
|
375
|
-
it "handles after_date operator" do
|
|
376
|
-
ctx = DecisionAgent::Context.new({ created_at: "2025-06-01" })
|
|
377
|
-
condition = { "field" => "created_at", "op" => "after_date", "value" => "2024-01-01" }
|
|
378
|
-
result = described_class.evaluate_field_condition(condition, ctx)
|
|
379
|
-
expect(result).to be true
|
|
380
|
-
end
|
|
381
|
-
|
|
382
|
-
it "handles within_days operator" do
|
|
383
|
-
future_date = (Time.now + (3 * 24 * 60 * 60)).strftime("%Y-%m-%d")
|
|
384
|
-
ctx = DecisionAgent::Context.new({ event_date: future_date })
|
|
385
|
-
condition = { "field" => "event_date", "op" => "within_days", "value" => 7 }
|
|
386
|
-
result = described_class.evaluate_field_condition(condition, ctx)
|
|
387
|
-
expect(result).to be true
|
|
388
|
-
end
|
|
389
|
-
|
|
390
|
-
it "handles day_of_week operator with string" do
|
|
391
|
-
monday_date = Time.now
|
|
392
|
-
monday_date += 24 * 60 * 60 until monday_date.wday == 1
|
|
393
|
-
ctx = DecisionAgent::Context.new({ appointment: monday_date.strftime("%Y-%m-%d") })
|
|
394
|
-
condition = { "field" => "appointment", "op" => "day_of_week", "value" => "monday" }
|
|
395
|
-
result = described_class.evaluate_field_condition(condition, ctx)
|
|
396
|
-
expect(result).to be true
|
|
397
|
-
end
|
|
398
|
-
|
|
399
|
-
it "handles day_of_week operator with numeric" do
|
|
400
|
-
monday_date = Time.now
|
|
401
|
-
monday_date += 24 * 60 * 60 until monday_date.wday == 1
|
|
402
|
-
ctx = DecisionAgent::Context.new({ appointment: monday_date.strftime("%Y-%m-%d") })
|
|
403
|
-
condition = { "field" => "appointment", "op" => "day_of_week", "value" => 1 }
|
|
404
|
-
result = described_class.evaluate_field_condition(condition, ctx)
|
|
405
|
-
expect(result).to be true
|
|
406
|
-
end
|
|
407
|
-
end
|
|
408
|
-
|
|
409
|
-
describe "collection operators" do
|
|
410
|
-
it "handles contains_all operator" do
|
|
411
|
-
ctx = DecisionAgent::Context.new({ permissions: %w[read write execute] })
|
|
412
|
-
condition = { "field" => "permissions", "op" => "contains_all", "value" => %w[read write] }
|
|
413
|
-
result = described_class.evaluate_field_condition(condition, ctx)
|
|
414
|
-
expect(result).to be true
|
|
415
|
-
end
|
|
416
|
-
|
|
417
|
-
it "handles contains_any operator" do
|
|
418
|
-
ctx = DecisionAgent::Context.new({ tags: %w[normal urgent] })
|
|
419
|
-
condition = { "field" => "tags", "op" => "contains_any", "value" => %w[urgent critical] }
|
|
420
|
-
result = described_class.evaluate_field_condition(condition, ctx)
|
|
421
|
-
expect(result).to be true
|
|
422
|
-
end
|
|
423
|
-
|
|
424
|
-
it "handles intersects operator" do
|
|
425
|
-
ctx = DecisionAgent::Context.new({ user_roles: %w[user moderator] })
|
|
426
|
-
condition = { "field" => "user_roles", "op" => "intersects", "value" => %w[admin moderator] }
|
|
427
|
-
result = described_class.evaluate_field_condition(condition, ctx)
|
|
428
|
-
expect(result).to be true
|
|
429
|
-
end
|
|
430
|
-
|
|
431
|
-
it "handles subset_of operator" do
|
|
432
|
-
ctx = DecisionAgent::Context.new({ selected_items: %w[a c] })
|
|
433
|
-
condition = { "field" => "selected_items", "op" => "subset_of", "value" => %w[a b c d] }
|
|
434
|
-
result = described_class.evaluate_field_condition(condition, ctx)
|
|
435
|
-
expect(result).to be true
|
|
436
|
-
end
|
|
437
|
-
|
|
438
|
-
it "returns false for collection operators with non-array values" do
|
|
439
|
-
condition = { "field" => "status", "op" => "contains_all", "value" => %w[read write] }
|
|
440
|
-
result = described_class.evaluate_field_condition(condition, context)
|
|
441
|
-
expect(result).to be false
|
|
442
|
-
end
|
|
443
|
-
end
|
|
444
|
-
|
|
445
|
-
describe "geospatial operators" do
|
|
446
|
-
it "handles within_radius operator" do
|
|
447
|
-
ctx = DecisionAgent::Context.new({ location: { lat: 40.7200, lon: -74.0000 } })
|
|
448
|
-
condition = {
|
|
449
|
-
"field" => "location",
|
|
450
|
-
"op" => "within_radius",
|
|
451
|
-
"value" => { "center" => { "lat" => 40.7128, "lon" => -74.0060 }, "radius" => 10 }
|
|
452
|
-
}
|
|
453
|
-
result = described_class.evaluate_field_condition(condition, ctx)
|
|
454
|
-
expect(result).to be true
|
|
455
|
-
end
|
|
456
|
-
|
|
457
|
-
it "handles in_polygon operator" do
|
|
458
|
-
polygon = [
|
|
459
|
-
{ "lat" => -1, "lon" => -1 },
|
|
460
|
-
{ "lat" => 1, "lon" => -1 },
|
|
461
|
-
{ "lat" => 1, "lon" => 1 },
|
|
462
|
-
{ "lat" => -1, "lon" => 1 }
|
|
463
|
-
]
|
|
464
|
-
ctx = DecisionAgent::Context.new({ location: { lat: 0, lon: 0 } })
|
|
465
|
-
condition = { "field" => "location", "op" => "in_polygon", "value" => polygon }
|
|
466
|
-
result = described_class.evaluate_field_condition(condition, ctx)
|
|
467
|
-
expect(result).to be true
|
|
468
|
-
end
|
|
469
|
-
|
|
470
|
-
it "returns false for in_polygon with less than 3 vertices" do
|
|
471
|
-
polygon = [
|
|
472
|
-
{ "lat" => -1, "lon" => -1 },
|
|
473
|
-
{ "lat" => 1, "lon" => -1 }
|
|
474
|
-
]
|
|
475
|
-
ctx = DecisionAgent::Context.new({ location: { lat: 0, lon: 0 } })
|
|
476
|
-
condition = { "field" => "location", "op" => "in_polygon", "value" => polygon }
|
|
477
|
-
result = described_class.evaluate_field_condition(condition, ctx)
|
|
478
|
-
expect(result).to be false
|
|
479
|
-
end
|
|
480
|
-
end
|
|
481
|
-
|
|
482
|
-
it "returns false for unknown operator" do
|
|
483
|
-
condition = { "field" => "status", "op" => "unknown_op", "value" => "active" }
|
|
484
|
-
result = described_class.evaluate_field_condition(condition, context)
|
|
485
|
-
expect(result).to be false
|
|
486
|
-
end
|
|
487
|
-
end
|
|
488
|
-
|
|
489
|
-
describe ".get_nested_value" do
|
|
490
|
-
it "retrieves simple value" do
|
|
491
|
-
hash = { status: "active" }
|
|
492
|
-
result = described_class.get_nested_value(hash, "status")
|
|
493
|
-
expect(result).to eq("active")
|
|
494
|
-
end
|
|
495
|
-
|
|
496
|
-
it "retrieves nested value with dot notation" do
|
|
497
|
-
hash = { user: { role: "admin" } }
|
|
498
|
-
result = described_class.get_nested_value(hash, "user.role")
|
|
499
|
-
expect(result).to eq("admin")
|
|
500
|
-
end
|
|
501
|
-
|
|
502
|
-
it "retrieves deeply nested value" do
|
|
503
|
-
hash = { user: { profile: { name: "John" } } }
|
|
504
|
-
result = described_class.get_nested_value(hash, "user.profile.name")
|
|
505
|
-
expect(result).to eq("John")
|
|
506
|
-
end
|
|
507
|
-
|
|
508
|
-
it "returns nil for missing key" do
|
|
509
|
-
hash = { user: { role: "admin" } }
|
|
510
|
-
result = described_class.get_nested_value(hash, "user.missing")
|
|
511
|
-
expect(result).to be_nil
|
|
512
|
-
end
|
|
513
|
-
|
|
514
|
-
it "returns nil when intermediate value is nil" do
|
|
515
|
-
hash = { user: nil }
|
|
516
|
-
result = described_class.get_nested_value(hash, "user.role")
|
|
517
|
-
expect(result).to be_nil
|
|
518
|
-
end
|
|
519
|
-
|
|
520
|
-
it "handles symbol keys" do
|
|
521
|
-
hash = { user: { role: "admin" } }
|
|
522
|
-
result = described_class.get_nested_value(hash, "user.role")
|
|
523
|
-
expect(result).to eq("admin")
|
|
524
|
-
end
|
|
525
|
-
|
|
526
|
-
it "returns nil for non-hash intermediate value" do
|
|
527
|
-
hash = { user: "not a hash" }
|
|
528
|
-
result = described_class.get_nested_value(hash, "user.role")
|
|
529
|
-
expect(result).to be_nil
|
|
530
|
-
end
|
|
531
|
-
end
|
|
532
|
-
|
|
533
|
-
describe ".comparable?" do
|
|
534
|
-
it "returns true for same numeric types" do
|
|
535
|
-
result = described_class.comparable?(10, 20)
|
|
536
|
-
expect(result).to be true
|
|
537
|
-
end
|
|
538
|
-
|
|
539
|
-
it "returns true for same string types" do
|
|
540
|
-
result = described_class.comparable?("a", "b")
|
|
541
|
-
expect(result).to be true
|
|
542
|
-
end
|
|
543
|
-
|
|
544
|
-
it "returns false for different numeric types" do
|
|
545
|
-
result = described_class.comparable?(10, 20.0)
|
|
546
|
-
expect(result).to be false
|
|
547
|
-
end
|
|
548
|
-
|
|
549
|
-
it "returns false for non-comparable types" do
|
|
550
|
-
result = described_class.comparable?({}, [])
|
|
551
|
-
expect(result).to be false
|
|
552
|
-
end
|
|
553
|
-
end
|
|
554
|
-
|
|
555
|
-
describe ".parse_range" do
|
|
556
|
-
it "parses array format" do
|
|
557
|
-
result = described_class.parse_range([10, 20])
|
|
558
|
-
expect(result).to eq({ min: 10, max: 20 })
|
|
559
|
-
end
|
|
560
|
-
|
|
561
|
-
it "parses hash format with string keys" do
|
|
562
|
-
result = described_class.parse_range({ "min" => 10, "max" => 20 })
|
|
563
|
-
expect(result).to eq({ min: 10, max: 20 })
|
|
564
|
-
end
|
|
565
|
-
|
|
566
|
-
it "parses hash format with symbol keys" do
|
|
567
|
-
result = described_class.parse_range({ min: 10, max: 20 })
|
|
568
|
-
expect(result).to eq({ min: 10, max: 20 })
|
|
569
|
-
end
|
|
570
|
-
|
|
571
|
-
it "returns nil for invalid array" do
|
|
572
|
-
result = described_class.parse_range([10])
|
|
573
|
-
expect(result).to be_nil
|
|
574
|
-
end
|
|
575
|
-
|
|
576
|
-
it "returns nil for invalid hash" do
|
|
577
|
-
result = described_class.parse_range({ min: 10 })
|
|
578
|
-
expect(result).to be_nil
|
|
579
|
-
end
|
|
580
|
-
end
|
|
581
|
-
|
|
582
|
-
describe ".parse_modulo_params" do
|
|
583
|
-
it "parses array format" do
|
|
584
|
-
result = described_class.parse_modulo_params([2, 0])
|
|
585
|
-
expect(result).to eq({ divisor: 2, remainder: 0 })
|
|
586
|
-
end
|
|
587
|
-
|
|
588
|
-
it "parses hash format with string keys" do
|
|
589
|
-
result = described_class.parse_modulo_params({ "divisor" => 2, "remainder" => 0 })
|
|
590
|
-
expect(result).to eq({ divisor: 2, remainder: 0 })
|
|
591
|
-
end
|
|
592
|
-
|
|
593
|
-
it "parses hash format with symbol keys" do
|
|
594
|
-
result = described_class.parse_modulo_params({ divisor: 2, remainder: 0 })
|
|
595
|
-
expect(result).to eq({ divisor: 2, remainder: 0 })
|
|
596
|
-
end
|
|
597
|
-
|
|
598
|
-
it "returns nil for invalid array" do
|
|
599
|
-
result = described_class.parse_modulo_params([2])
|
|
600
|
-
expect(result).to be_nil
|
|
601
|
-
end
|
|
602
|
-
|
|
603
|
-
it "returns nil for invalid hash" do
|
|
604
|
-
result = described_class.parse_modulo_params({ divisor: 2 })
|
|
605
|
-
expect(result).to be_nil
|
|
606
|
-
end
|
|
607
|
-
end
|
|
608
|
-
|
|
609
|
-
describe ".parse_date" do
|
|
610
|
-
it "parses Time object" do
|
|
611
|
-
time = Time.now
|
|
612
|
-
result = described_class.parse_date(time)
|
|
613
|
-
expect(result).to eq(time)
|
|
614
|
-
end
|
|
615
|
-
|
|
616
|
-
it "parses Date object" do
|
|
617
|
-
date = Date.today
|
|
618
|
-
result = described_class.parse_date(date)
|
|
619
|
-
expect(result).to eq(date)
|
|
620
|
-
end
|
|
621
|
-
|
|
622
|
-
it "parses DateTime object" do
|
|
623
|
-
datetime = DateTime.now
|
|
624
|
-
result = described_class.parse_date(datetime)
|
|
625
|
-
expect(result).to eq(datetime)
|
|
626
|
-
end
|
|
627
|
-
|
|
628
|
-
it "parses string date" do
|
|
629
|
-
result = described_class.parse_date("2025-01-01")
|
|
630
|
-
expect(result).to be_a(Time)
|
|
631
|
-
end
|
|
632
|
-
|
|
633
|
-
it "returns nil for invalid string" do
|
|
634
|
-
result = described_class.parse_date("invalid")
|
|
635
|
-
expect(result).to be_nil
|
|
636
|
-
end
|
|
637
|
-
end
|
|
638
|
-
|
|
639
|
-
describe ".compare_dates" do
|
|
640
|
-
it "compares dates with < operator" do
|
|
641
|
-
result = described_class.compare_dates("2025-01-01", "2025-12-31", :<)
|
|
642
|
-
expect(result).to be true
|
|
643
|
-
end
|
|
644
|
-
|
|
645
|
-
it "compares dates with > operator" do
|
|
646
|
-
result = described_class.compare_dates("2025-12-31", "2025-01-01", :>)
|
|
647
|
-
expect(result).to be true
|
|
648
|
-
end
|
|
649
|
-
|
|
650
|
-
it "returns false for invalid dates" do
|
|
651
|
-
result = described_class.compare_dates("invalid", "2025-01-01", :<)
|
|
652
|
-
expect(result).to be false
|
|
653
|
-
end
|
|
654
|
-
end
|
|
655
|
-
|
|
656
|
-
describe ".normalize_day_of_week" do
|
|
657
|
-
it "normalizes numeric day" do
|
|
658
|
-
result = described_class.normalize_day_of_week(1)
|
|
659
|
-
expect(result).to eq(1)
|
|
660
|
-
end
|
|
661
|
-
|
|
662
|
-
it "normalizes string day" do
|
|
663
|
-
result = described_class.normalize_day_of_week("monday")
|
|
664
|
-
expect(result).to eq(1)
|
|
665
|
-
end
|
|
666
|
-
|
|
667
|
-
it "normalizes abbreviated day" do
|
|
668
|
-
result = described_class.normalize_day_of_week("mon")
|
|
669
|
-
expect(result).to eq(1)
|
|
670
|
-
end
|
|
671
|
-
|
|
672
|
-
it "returns nil for invalid day" do
|
|
673
|
-
result = described_class.normalize_day_of_week("invalid")
|
|
674
|
-
expect(result).to be_nil
|
|
675
|
-
end
|
|
676
|
-
end
|
|
677
|
-
|
|
678
|
-
describe ".parse_coordinates" do
|
|
679
|
-
it "parses hash with lat/lon" do
|
|
680
|
-
result = described_class.parse_coordinates({ lat: 40.7128, lon: -74.0060 })
|
|
681
|
-
expect(result).to eq({ lat: 40.7128, lon: -74.0060 })
|
|
682
|
-
end
|
|
683
|
-
|
|
684
|
-
it "parses hash with latitude/longitude" do
|
|
685
|
-
result = described_class.parse_coordinates({ latitude: 40.7128, longitude: -74.0060 })
|
|
686
|
-
expect(result).to eq({ lat: 40.7128, lon: -74.0060 })
|
|
687
|
-
end
|
|
688
|
-
|
|
689
|
-
it "parses array format" do
|
|
690
|
-
result = described_class.parse_coordinates([40.7128, -74.0060])
|
|
691
|
-
expect(result).to eq({ lat: 40.7128, lon: -74.0060 })
|
|
692
|
-
end
|
|
693
|
-
|
|
694
|
-
it "returns nil for invalid hash" do
|
|
695
|
-
result = described_class.parse_coordinates({ lat: 40.7128 })
|
|
696
|
-
expect(result).to be_nil
|
|
697
|
-
end
|
|
698
|
-
|
|
699
|
-
it "returns nil for invalid array" do
|
|
700
|
-
result = described_class.parse_coordinates([40.7128])
|
|
701
|
-
expect(result).to be_nil
|
|
702
|
-
end
|
|
703
|
-
end
|
|
704
|
-
|
|
705
|
-
describe ".parse_radius_params" do
|
|
706
|
-
it "parses radius parameters" do
|
|
707
|
-
params = {
|
|
708
|
-
"center" => { "lat" => 40.7128, "lon" => -74.0060 },
|
|
709
|
-
"radius" => 10
|
|
710
|
-
}
|
|
711
|
-
result = described_class.parse_radius_params(params)
|
|
712
|
-
expect(result[:center]).to eq({ lat: 40.7128, lon: -74.0060 })
|
|
713
|
-
expect(result[:radius]).to eq(10.0)
|
|
714
|
-
end
|
|
715
|
-
|
|
716
|
-
it "returns nil for invalid params" do
|
|
717
|
-
result = described_class.parse_radius_params({ center: { lat: 40.7128 } })
|
|
718
|
-
expect(result).to be_nil
|
|
719
|
-
end
|
|
720
|
-
end
|
|
721
|
-
|
|
722
|
-
describe ".parse_polygon" do
|
|
723
|
-
it "parses polygon vertices" do
|
|
724
|
-
vertices = [
|
|
725
|
-
{ lat: -1, lon: -1 },
|
|
726
|
-
{ lat: 1, lon: -1 },
|
|
727
|
-
{ lat: 1, lon: 1 }
|
|
728
|
-
]
|
|
729
|
-
result = described_class.parse_polygon(vertices)
|
|
730
|
-
expect(result.size).to eq(3)
|
|
731
|
-
end
|
|
732
|
-
|
|
733
|
-
it "returns nil for non-array" do
|
|
734
|
-
result = described_class.parse_polygon("not an array")
|
|
735
|
-
expect(result).to be_nil
|
|
736
|
-
end
|
|
737
|
-
end
|
|
738
|
-
|
|
739
|
-
describe ".haversine_distance" do
|
|
740
|
-
it "calculates distance between two points" do
|
|
741
|
-
point1 = { lat: 40.7128, lon: -74.0060 }
|
|
742
|
-
point2 = { lat: 40.7200, lon: -74.0000 }
|
|
743
|
-
result = described_class.haversine_distance(point1, point2)
|
|
744
|
-
expect(result).to be_a(Numeric)
|
|
745
|
-
expect(result).to be >= 0
|
|
746
|
-
end
|
|
747
|
-
end
|
|
748
|
-
|
|
749
|
-
describe ".point_in_polygon?" do
|
|
750
|
-
it "detects point inside polygon" do
|
|
751
|
-
point = { lat: 0, lon: 0 }
|
|
752
|
-
polygon = [
|
|
753
|
-
{ lat: -1, lon: -1 },
|
|
754
|
-
{ lat: 1, lon: -1 },
|
|
755
|
-
{ lat: 1, lon: 1 },
|
|
756
|
-
{ lat: -1, lon: 1 }
|
|
757
|
-
]
|
|
758
|
-
result = described_class.point_in_polygon?(point, polygon)
|
|
759
|
-
expect(result).to be true
|
|
760
|
-
end
|
|
761
|
-
|
|
762
|
-
it "detects point outside polygon" do
|
|
763
|
-
point = { lat: 5, lon: 5 }
|
|
764
|
-
polygon = [
|
|
765
|
-
{ lat: -1, lon: -1 },
|
|
766
|
-
{ lat: 1, lon: -1 },
|
|
767
|
-
{ lat: 1, lon: 1 },
|
|
768
|
-
{ lat: -1, lon: 1 }
|
|
769
|
-
]
|
|
770
|
-
result = described_class.point_in_polygon?(point, polygon)
|
|
771
|
-
expect(result).to be false
|
|
772
|
-
end
|
|
773
|
-
end
|
|
774
|
-
end
|