kbs 0.0.1 → 0.1.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/.github/workflows/deploy-github-pages.yml +52 -0
- data/CHANGELOG.md +68 -2
- data/README.md +235 -334
- data/docs/DOCUMENTATION_STATUS.md +158 -0
- data/docs/advanced/custom-persistence.md +775 -0
- data/docs/advanced/debugging.md +726 -0
- data/docs/advanced/index.md +8 -0
- data/docs/advanced/performance.md +832 -0
- data/docs/advanced/testing.md +691 -0
- data/docs/api/blackboard.md +1157 -0
- data/docs/api/engine.md +978 -0
- data/docs/api/facts.md +1212 -0
- data/docs/api/index.md +12 -0
- data/docs/api/rules.md +1034 -0
- data/docs/architecture/blackboard.md +553 -0
- data/docs/architecture/index.md +277 -0
- data/docs/architecture/network-structure.md +343 -0
- data/docs/architecture/rete-algorithm.md +737 -0
- data/docs/assets/css/custom.css +83 -0
- data/docs/assets/images/blackboard-architecture.svg +136 -0
- data/docs/assets/images/compiled-network.svg +101 -0
- data/docs/assets/images/fact-assertion-flow.svg +117 -0
- data/docs/assets/images/kbs.jpg +0 -0
- data/docs/assets/images/pattern-matching-trace.svg +136 -0
- data/docs/assets/images/rete-network-layers.svg +96 -0
- data/docs/assets/images/system-layers.svg +69 -0
- data/docs/assets/images/trading-signal-network.svg +139 -0
- data/docs/assets/js/mathjax.js +17 -0
- data/docs/examples/expert-systems.md +1031 -0
- data/docs/examples/index.md +9 -0
- data/docs/examples/multi-agent.md +1335 -0
- data/docs/examples/stock-trading.md +488 -0
- data/docs/guides/blackboard-memory.md +558 -0
- data/docs/guides/dsl.md +1321 -0
- data/docs/guides/facts.md +652 -0
- data/docs/guides/getting-started.md +383 -0
- data/docs/guides/index.md +23 -0
- data/docs/guides/negation.md +529 -0
- data/docs/guides/pattern-matching.md +561 -0
- data/docs/guides/persistence.md +451 -0
- data/docs/guides/variable-binding.md +491 -0
- data/docs/guides/writing-rules.md +755 -0
- data/docs/index.md +157 -0
- data/docs/installation.md +156 -0
- data/docs/quick-start.md +228 -0
- data/examples/README.md +2 -2
- data/examples/advanced_example.rb +2 -2
- data/examples/advanced_example_dsl.rb +224 -0
- data/examples/ai_enhanced_kbs.rb +1 -1
- data/examples/ai_enhanced_kbs_dsl.rb +538 -0
- data/examples/blackboard_demo_dsl.rb +50 -0
- data/examples/car_diagnostic.rb +1 -1
- data/examples/car_diagnostic_dsl.rb +54 -0
- data/examples/concurrent_inference_demo.rb +5 -5
- data/examples/concurrent_inference_demo_dsl.rb +363 -0
- data/examples/csv_trading_system.rb +1 -1
- data/examples/csv_trading_system_dsl.rb +525 -0
- data/examples/knowledge_base.db +0 -0
- data/examples/portfolio_rebalancing_system.rb +2 -2
- data/examples/portfolio_rebalancing_system_dsl.rb +613 -0
- data/examples/redis_trading_demo_dsl.rb +177 -0
- data/examples/run_all.rb +50 -0
- data/examples/run_all_dsl.rb +49 -0
- data/examples/stock_trading_advanced.rb +1 -1
- data/examples/stock_trading_advanced_dsl.rb +404 -0
- data/examples/temp.txt +7693 -0
- data/examples/temp_dsl.txt +8447 -0
- data/examples/timestamped_trading.rb +1 -1
- data/examples/timestamped_trading_dsl.rb +258 -0
- data/examples/trading_demo.rb +1 -1
- data/examples/trading_demo_dsl.rb +322 -0
- data/examples/working_demo.rb +1 -1
- data/examples/working_demo_dsl.rb +160 -0
- data/lib/kbs/blackboard/engine.rb +3 -3
- data/lib/kbs/blackboard/fact.rb +1 -1
- data/lib/kbs/condition.rb +1 -1
- data/lib/kbs/dsl/knowledge_base.rb +1 -1
- data/lib/kbs/dsl/variable.rb +1 -1
- data/lib/kbs/{rete_engine.rb → engine.rb} +1 -1
- data/lib/kbs/fact.rb +1 -1
- data/lib/kbs/version.rb +1 -1
- data/lib/kbs.rb +2 -2
- data/mkdocs.yml +181 -0
- metadata +66 -6
- data/examples/stock_trading_system.rb.bak +0 -563
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
# Testing Rules
|
|
2
|
+
|
|
3
|
+
Comprehensive testing strategies for rule-based systems. This guide covers unit testing, integration testing, test fixtures, and coverage analysis for KBS applications.
|
|
4
|
+
|
|
5
|
+
## Testing Overview
|
|
6
|
+
|
|
7
|
+
Rule-based systems require testing at multiple levels:
|
|
8
|
+
|
|
9
|
+
1. **Unit Tests** - Test individual rules in isolation
|
|
10
|
+
2. **Integration Tests** - Test rule interactions
|
|
11
|
+
3. **Fact Fixtures** - Reusable test data
|
|
12
|
+
4. **Coverage** - Ensure all rules and conditions are tested
|
|
13
|
+
5. **Performance Tests** - Verify rule execution speed
|
|
14
|
+
|
|
15
|
+
## Setup
|
|
16
|
+
|
|
17
|
+
### Test Framework
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
# Gemfile
|
|
21
|
+
group :test do
|
|
22
|
+
gem 'minitest', '~> 5.0'
|
|
23
|
+
gem 'simplecov', require: false # Coverage
|
|
24
|
+
end
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Test Helper
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
# test/test_helper.rb
|
|
31
|
+
require 'simplecov'
|
|
32
|
+
SimpleCov.start
|
|
33
|
+
|
|
34
|
+
require 'minitest/autorun'
|
|
35
|
+
require 'kbs'
|
|
36
|
+
|
|
37
|
+
class Minitest::Test
|
|
38
|
+
def setup_engine
|
|
39
|
+
KBS::Engine.new
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def assert_rule_fired(engine, rule_name)
|
|
43
|
+
# Check if rule action was executed
|
|
44
|
+
# Implementation depends on tracking mechanism
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def refute_rule_fired(engine, rule_name)
|
|
48
|
+
# Check that rule did not fire
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Unit Testing Rules
|
|
54
|
+
|
|
55
|
+
### Test Single Rule
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
require 'test_helper'
|
|
59
|
+
|
|
60
|
+
class TestTemperatureRule < Minitest::Test
|
|
61
|
+
def setup
|
|
62
|
+
@engine = setup_engine
|
|
63
|
+
@fired = false
|
|
64
|
+
|
|
65
|
+
# Create test rule
|
|
66
|
+
@rule = KBS::Rule.new("high_temp_alert", priority: 100) do |r|
|
|
67
|
+
r.conditions = [
|
|
68
|
+
KBS::Condition.new(:sensor, {
|
|
69
|
+
type: "temperature",
|
|
70
|
+
value: :temp?
|
|
71
|
+
}, predicate: lambda { |f| f[:value] > 30 })
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
r.action = lambda do |facts, bindings|
|
|
75
|
+
@fired = true
|
|
76
|
+
@engine.add_fact(:alert, {
|
|
77
|
+
type: "high_temperature",
|
|
78
|
+
temperature: bindings[:temp?]
|
|
79
|
+
})
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
@engine.add_rule(@rule)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def test_fires_when_temperature_high
|
|
87
|
+
@engine.add_fact(:sensor, { type: "temperature", value: 35 })
|
|
88
|
+
@engine.run
|
|
89
|
+
|
|
90
|
+
assert @fired, "Rule should fire for high temperature"
|
|
91
|
+
|
|
92
|
+
alerts = @engine.facts.select { |f| f.type == :alert }
|
|
93
|
+
assert_equal 1, alerts.size
|
|
94
|
+
assert_equal 35, alerts.first[:temperature]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def test_does_not_fire_when_temperature_normal
|
|
98
|
+
@engine.add_fact(:sensor, { type: "temperature", value: 25 })
|
|
99
|
+
@engine.run
|
|
100
|
+
|
|
101
|
+
refute @fired, "Rule should not fire for normal temperature"
|
|
102
|
+
|
|
103
|
+
alerts = @engine.facts.select { |f| f.type == :alert }
|
|
104
|
+
assert_empty alerts
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def test_threshold_boundary
|
|
108
|
+
# Test at exact threshold
|
|
109
|
+
@engine.add_fact(:sensor, { type: "temperature", value: 30 })
|
|
110
|
+
@engine.run
|
|
111
|
+
|
|
112
|
+
refute @fired, "Rule should not fire at exact threshold (>= not >)"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Test Rule with Multiple Conditions
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
class TestMultiConditionRule < Minitest::Test
|
|
121
|
+
def setup
|
|
122
|
+
@engine = setup_engine
|
|
123
|
+
@fired = false
|
|
124
|
+
|
|
125
|
+
@rule = KBS::Rule.new("high_temp_and_low_humidity") do |r|
|
|
126
|
+
r.conditions = [
|
|
127
|
+
KBS::Condition.new(:temperature, {
|
|
128
|
+
location: :loc?,
|
|
129
|
+
value: :temp?
|
|
130
|
+
}, predicate: lambda { |f| f[:value] > 30 }),
|
|
131
|
+
|
|
132
|
+
KBS::Condition.new(:humidity, {
|
|
133
|
+
location: :loc?,
|
|
134
|
+
value: :hum?
|
|
135
|
+
}, predicate: lambda { |f| f[:value] < 40 })
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
r.action = lambda do |facts, bindings|
|
|
139
|
+
@fired = true
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
@engine.add_rule(@rule)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def test_fires_when_both_conditions_met
|
|
147
|
+
@engine.add_fact(:temperature, { location: "room1", value: 35 })
|
|
148
|
+
@engine.add_fact(:humidity, { location: "room1", value: 30 })
|
|
149
|
+
@engine.run
|
|
150
|
+
|
|
151
|
+
assert @fired, "Rule should fire when both conditions met"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def test_does_not_fire_with_mismatched_locations
|
|
155
|
+
@engine.add_fact(:temperature, { location: "room1", value: 35 })
|
|
156
|
+
@engine.add_fact(:humidity, { location: "room2", value: 30 })
|
|
157
|
+
@engine.run
|
|
158
|
+
|
|
159
|
+
refute @fired, "Rule should not fire with different locations"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def test_does_not_fire_when_only_temperature_high
|
|
163
|
+
@engine.add_fact(:temperature, { location: "room1", value: 35 })
|
|
164
|
+
# No humidity fact
|
|
165
|
+
@engine.run
|
|
166
|
+
|
|
167
|
+
refute @fired, "Rule should not fire without humidity fact"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def test_does_not_fire_when_temperature_normal
|
|
171
|
+
@engine.add_fact(:temperature, { location: "room1", value: 25 })
|
|
172
|
+
@engine.add_fact(:humidity, { location: "room1", value: 30 })
|
|
173
|
+
@engine.run
|
|
174
|
+
|
|
175
|
+
refute @fired, "Rule should not fire with normal temperature"
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Test Negated Conditions
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
class TestNegationRule < Minitest::Test
|
|
184
|
+
def setup
|
|
185
|
+
@engine = setup_engine
|
|
186
|
+
@fired = false
|
|
187
|
+
|
|
188
|
+
@rule = KBS::Rule.new("alert_if_no_acknowledgment") do |r|
|
|
189
|
+
r.conditions = [
|
|
190
|
+
KBS::Condition.new(:error, { id: :id? }),
|
|
191
|
+
KBS::Condition.new(:acknowledged, { error_id: :id? }, negated: true)
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
r.action = lambda do |facts, bindings|
|
|
195
|
+
@fired = true
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
@engine.add_rule(@rule)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def test_fires_when_error_not_acknowledged
|
|
203
|
+
@engine.add_fact(:error, { id: 1 })
|
|
204
|
+
@engine.run
|
|
205
|
+
|
|
206
|
+
assert @fired, "Rule should fire when error not acknowledged"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def test_does_not_fire_when_error_acknowledged
|
|
210
|
+
@engine.add_fact(:error, { id: 1 })
|
|
211
|
+
@engine.add_fact(:acknowledged, { error_id: 1 })
|
|
212
|
+
@engine.run
|
|
213
|
+
|
|
214
|
+
refute @fired, "Rule should not fire when error acknowledged"
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## Integration Testing
|
|
220
|
+
|
|
221
|
+
### Test Rule Interactions
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
class TestRuleInteractions < Minitest::Test
|
|
225
|
+
def setup
|
|
226
|
+
@engine = setup_engine
|
|
227
|
+
@alerts = []
|
|
228
|
+
|
|
229
|
+
# Rule 1: Detect high temperature
|
|
230
|
+
@engine.add_rule(KBS::Rule.new("detect_high_temp") do |r|
|
|
231
|
+
r.conditions = [
|
|
232
|
+
KBS::Condition.new(:sensor, { value: :temp? }, predicate: lambda { |f| f[:value] > 30 })
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
r.action = lambda do |facts, bindings|
|
|
236
|
+
@engine.add_fact(:temp_alert, { severity: "high" })
|
|
237
|
+
end
|
|
238
|
+
end)
|
|
239
|
+
|
|
240
|
+
# Rule 2: Escalate to critical
|
|
241
|
+
@engine.add_rule(KBS::Rule.new("escalate_critical") do |r|
|
|
242
|
+
r.conditions = [
|
|
243
|
+
KBS::Condition.new(:temp_alert, { severity: "high" }),
|
|
244
|
+
KBS::Condition.new(:sensor, { value: :temp? }, predicate: lambda { |f| f[:value] > 40 })
|
|
245
|
+
]
|
|
246
|
+
|
|
247
|
+
r.action = lambda do |facts, bindings|
|
|
248
|
+
@engine.add_fact(:critical_alert, { type: "temperature" })
|
|
249
|
+
@alerts << :critical
|
|
250
|
+
end
|
|
251
|
+
end)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def test_cascading_rules
|
|
255
|
+
# Add high temperature
|
|
256
|
+
@engine.add_fact(:sensor, { value: 45 })
|
|
257
|
+
@engine.run
|
|
258
|
+
|
|
259
|
+
# Both rules should fire
|
|
260
|
+
assert @engine.facts.any? { |f| f.type == :temp_alert }
|
|
261
|
+
assert @engine.facts.any? { |f| f.type == :critical_alert }
|
|
262
|
+
assert_includes @alerts, :critical
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def test_partial_cascade
|
|
266
|
+
# Add moderately high temperature
|
|
267
|
+
@engine.add_fact(:sensor, { value: 35 })
|
|
268
|
+
@engine.run
|
|
269
|
+
|
|
270
|
+
# Only first rule fires
|
|
271
|
+
assert @engine.facts.any? { |f| f.type == :temp_alert }
|
|
272
|
+
refute @engine.facts.any? { |f| f.type == :critical_alert }
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
### Test Rule Priority
|
|
278
|
+
|
|
279
|
+
```ruby
|
|
280
|
+
class TestRulePriority < Minitest::Test
|
|
281
|
+
def setup
|
|
282
|
+
@engine = setup_engine
|
|
283
|
+
@execution_order = []
|
|
284
|
+
|
|
285
|
+
# High priority rule
|
|
286
|
+
@engine.add_rule(KBS::Rule.new("high_priority", priority: 100) do |r|
|
|
287
|
+
r.conditions = [KBS::Condition.new(:trigger, {})]
|
|
288
|
+
r.action = lambda do |facts, bindings|
|
|
289
|
+
@execution_order << :high
|
|
290
|
+
end
|
|
291
|
+
end)
|
|
292
|
+
|
|
293
|
+
# Low priority rule
|
|
294
|
+
@engine.add_rule(KBS::Rule.new("low_priority", priority: 10) do |r|
|
|
295
|
+
r.conditions = [KBS::Condition.new(:trigger, {})]
|
|
296
|
+
r.action = lambda do |facts, bindings|
|
|
297
|
+
@execution_order << :low
|
|
298
|
+
end
|
|
299
|
+
end)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def test_executes_in_priority_order
|
|
303
|
+
@engine.add_fact(:trigger, {})
|
|
304
|
+
@engine.run
|
|
305
|
+
|
|
306
|
+
assert_equal [:high, :low], @execution_order
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
## Test Fixtures
|
|
312
|
+
|
|
313
|
+
### Fact Fixtures
|
|
314
|
+
|
|
315
|
+
```ruby
|
|
316
|
+
module FactFixtures
|
|
317
|
+
def sensor_facts(count: 10)
|
|
318
|
+
count.times.map do |i|
|
|
319
|
+
{ type: :sensor, attributes: { id: i, value: rand(20..40) } }
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def high_temp_scenario
|
|
324
|
+
[
|
|
325
|
+
{ type: :sensor, attributes: { location: "room1", value: 35 } },
|
|
326
|
+
{ type: :sensor, attributes: { location: "room2", value: 38 } },
|
|
327
|
+
{ type: :threshold, attributes: { value: 30 } }
|
|
328
|
+
]
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def normal_scenario
|
|
332
|
+
[
|
|
333
|
+
{ type: :sensor, attributes: { location: "room1", value: 22 } },
|
|
334
|
+
{ type: :sensor, attributes: { location: "room2", value: 24 } },
|
|
335
|
+
{ type: :threshold, attributes: { value: 30 } }
|
|
336
|
+
]
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def load_facts(engine, facts)
|
|
340
|
+
facts.each do |fact_data|
|
|
341
|
+
engine.add_fact(fact_data[:type], fact_data[:attributes])
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
class TestWithFixtures < Minitest::Test
|
|
347
|
+
include FactFixtures
|
|
348
|
+
|
|
349
|
+
def test_with_high_temp_scenario
|
|
350
|
+
engine = setup_engine
|
|
351
|
+
# Add rules...
|
|
352
|
+
|
|
353
|
+
load_facts(engine, high_temp_scenario)
|
|
354
|
+
engine.run
|
|
355
|
+
|
|
356
|
+
# Assertions...
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
### Rule Fixtures
|
|
362
|
+
|
|
363
|
+
```ruby
|
|
364
|
+
module RuleFixtures
|
|
365
|
+
def temperature_monitoring_rules
|
|
366
|
+
[
|
|
367
|
+
KBS::Rule.new("detect_high") do |r|
|
|
368
|
+
r.conditions = [
|
|
369
|
+
KBS::Condition.new(:sensor, { value: :v? }, predicate: lambda { |f| f[:value] > 30 })
|
|
370
|
+
]
|
|
371
|
+
r.action = lambda { |facts, bindings| facts[0][:alerted] = true }
|
|
372
|
+
end,
|
|
373
|
+
|
|
374
|
+
KBS::Rule.new("detect_low") do |r|
|
|
375
|
+
r.conditions = [
|
|
376
|
+
KBS::Condition.new(:sensor, { value: :v? }, predicate: lambda { |f| f[:value] < 15 })
|
|
377
|
+
]
|
|
378
|
+
r.action = lambda { |facts, bindings| facts[0][:alerted] = true }
|
|
379
|
+
end
|
|
380
|
+
]
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def load_rules(engine, rules)
|
|
384
|
+
rules.each { |rule| engine.add_rule(rule) }
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
## Coverage Strategies
|
|
390
|
+
|
|
391
|
+
### Track Rule Firings
|
|
392
|
+
|
|
393
|
+
```ruby
|
|
394
|
+
class CoverageTracker
|
|
395
|
+
def initialize(engine)
|
|
396
|
+
@engine = engine
|
|
397
|
+
@rule_firings = Hash.new(0)
|
|
398
|
+
@condition_matches = Hash.new(0)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def wrap_rules
|
|
402
|
+
@engine.instance_variable_get(:@rules).each do |rule|
|
|
403
|
+
original_action = rule.action
|
|
404
|
+
|
|
405
|
+
rule.action = lambda do |facts, bindings|
|
|
406
|
+
@rule_firings[rule.name] += 1
|
|
407
|
+
original_action.call(facts, bindings)
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def report
|
|
413
|
+
puts "\n=== Coverage Report ==="
|
|
414
|
+
|
|
415
|
+
total_rules = @engine.instance_variable_get(:@rules).size
|
|
416
|
+
fired_rules = @rule_firings.keys.size
|
|
417
|
+
coverage = (fired_rules.to_f / total_rules * 100).round(2)
|
|
418
|
+
|
|
419
|
+
puts "Rules: #{fired_rules}/#{total_rules} (#{coverage}%)"
|
|
420
|
+
|
|
421
|
+
puts "\nRule Firings:"
|
|
422
|
+
@rule_firings.each do |name, count|
|
|
423
|
+
puts " #{name}: #{count}"
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
untested = @engine.instance_variable_get(:@rules).map(&:name) - @rule_firings.keys
|
|
427
|
+
if untested.any?
|
|
428
|
+
puts "\nUntested Rules:"
|
|
429
|
+
untested.each { |name| puts " - #{name}" }
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
attr_reader :rule_firings, :condition_matches
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Usage
|
|
437
|
+
class TestWithCoverage < Minitest::Test
|
|
438
|
+
def test_coverage
|
|
439
|
+
engine = setup_engine
|
|
440
|
+
# Add rules...
|
|
441
|
+
|
|
442
|
+
tracker = CoverageTracker.new(engine)
|
|
443
|
+
tracker.wrap_rules
|
|
444
|
+
|
|
445
|
+
# Add facts and run
|
|
446
|
+
engine.run
|
|
447
|
+
|
|
448
|
+
tracker.report
|
|
449
|
+
|
|
450
|
+
# Assert all rules fired
|
|
451
|
+
assert_equal @engine.instance_variable_get(:@rules).size, tracker.rule_firings.size
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
### Condition Coverage
|
|
457
|
+
|
|
458
|
+
```ruby
|
|
459
|
+
def test_all_condition_paths
|
|
460
|
+
engine = setup_engine
|
|
461
|
+
|
|
462
|
+
rule = KBS::Rule.new("multi_path") do |r|
|
|
463
|
+
r.conditions = [
|
|
464
|
+
KBS::Condition.new(:a, {}),
|
|
465
|
+
KBS::Condition.new(:b, {}),
|
|
466
|
+
KBS::Condition.new(:c, {}, negated: true)
|
|
467
|
+
]
|
|
468
|
+
r.action = lambda { |facts, bindings| }
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
engine.add_rule(rule)
|
|
472
|
+
|
|
473
|
+
# Test path 1: All conditions pass
|
|
474
|
+
engine.add_fact(:a, {})
|
|
475
|
+
engine.add_fact(:b, {})
|
|
476
|
+
# c absent
|
|
477
|
+
engine.run
|
|
478
|
+
# Assert...
|
|
479
|
+
|
|
480
|
+
# Test path 2: Negation fails
|
|
481
|
+
engine = setup_engine
|
|
482
|
+
engine.add_rule(rule)
|
|
483
|
+
engine.add_fact(:a, {})
|
|
484
|
+
engine.add_fact(:b, {})
|
|
485
|
+
engine.add_fact(:c, {}) # Blocks negation
|
|
486
|
+
engine.run
|
|
487
|
+
# Assert...
|
|
488
|
+
|
|
489
|
+
# Test path 3: Positive condition missing
|
|
490
|
+
engine = setup_engine
|
|
491
|
+
engine.add_rule(rule)
|
|
492
|
+
engine.add_fact(:a, {})
|
|
493
|
+
# b missing
|
|
494
|
+
engine.run
|
|
495
|
+
# Assert...
|
|
496
|
+
end
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
## Performance Testing
|
|
500
|
+
|
|
501
|
+
### Benchmark Rule Execution
|
|
502
|
+
|
|
503
|
+
```ruby
|
|
504
|
+
require 'benchmark'
|
|
505
|
+
|
|
506
|
+
class PerformanceTest < Minitest::Test
|
|
507
|
+
def test_rule_performance
|
|
508
|
+
engine = setup_engine
|
|
509
|
+
|
|
510
|
+
# Add rule
|
|
511
|
+
engine.add_rule(KBS::Rule.new("perf_test") do |r|
|
|
512
|
+
r.conditions = [
|
|
513
|
+
KBS::Condition.new(:data, { value: :v? })
|
|
514
|
+
]
|
|
515
|
+
r.action = lambda { |facts, bindings| }
|
|
516
|
+
end)
|
|
517
|
+
|
|
518
|
+
# Add many facts
|
|
519
|
+
1000.times { |i| engine.add_fact(:data, { value: i }) }
|
|
520
|
+
|
|
521
|
+
# Benchmark
|
|
522
|
+
time = Benchmark.measure { engine.run }
|
|
523
|
+
|
|
524
|
+
assert time.real < 1.0, "Engine should complete in under 1 second"
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def test_fact_addition_performance
|
|
528
|
+
engine = setup_engine
|
|
529
|
+
|
|
530
|
+
time = Benchmark.measure do
|
|
531
|
+
10_000.times { |i| engine.add_fact(:data, { value: i }) }
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
rate = 10_000 / time.real
|
|
535
|
+
assert rate > 10_000, "Should add >10k facts/sec, got #{rate.round(2)}"
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
## Testing Blackboard Persistence
|
|
541
|
+
|
|
542
|
+
### Test with SQLite
|
|
543
|
+
|
|
544
|
+
```ruby
|
|
545
|
+
class TestBlackboardPersistence < Minitest::Test
|
|
546
|
+
def test_facts_persist_across_sessions
|
|
547
|
+
# Session 1: Add facts
|
|
548
|
+
engine1 = KBS::Blackboard::Engine.new(db_path: 'test.db')
|
|
549
|
+
engine1.add_fact(:sensor, { id: 1, value: 25 })
|
|
550
|
+
engine1.close
|
|
551
|
+
|
|
552
|
+
# Session 2: Load facts
|
|
553
|
+
engine2 = KBS::Blackboard::Engine.new(db_path: 'test.db')
|
|
554
|
+
assert_equal 1, engine2.facts.size
|
|
555
|
+
assert_equal 25, engine2.facts.first[:value]
|
|
556
|
+
|
|
557
|
+
engine2.close
|
|
558
|
+
File.delete('test.db') if File.exist?('test.db')
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
def test_audit_trail
|
|
562
|
+
engine = KBS::Blackboard::Engine.new(db_path: ':memory:')
|
|
563
|
+
|
|
564
|
+
fact = engine.add_fact(:data, { value: 1 })
|
|
565
|
+
engine.update_fact(fact.id, { value: 2 })
|
|
566
|
+
engine.delete_fact(fact.id)
|
|
567
|
+
|
|
568
|
+
history = engine.fact_history(fact.id)
|
|
569
|
+
|
|
570
|
+
assert_equal 3, history.size
|
|
571
|
+
assert_equal "add", history[0][:operation]
|
|
572
|
+
assert_equal "update", history[1][:operation]
|
|
573
|
+
assert_equal "delete", history[2][:operation]
|
|
574
|
+
end
|
|
575
|
+
end
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
## Testing Best Practices
|
|
579
|
+
|
|
580
|
+
### 1. Isolate Rules
|
|
581
|
+
|
|
582
|
+
```ruby
|
|
583
|
+
def test_single_rule_only
|
|
584
|
+
engine = setup_engine
|
|
585
|
+
|
|
586
|
+
# Add ONLY the rule being tested
|
|
587
|
+
engine.add_rule(my_test_rule)
|
|
588
|
+
|
|
589
|
+
# No other rules to interfere
|
|
590
|
+
engine.run
|
|
591
|
+
end
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
### 2. Test Edge Cases
|
|
595
|
+
|
|
596
|
+
```ruby
|
|
597
|
+
def test_edge_cases
|
|
598
|
+
# Empty facts
|
|
599
|
+
engine.run
|
|
600
|
+
assert_empty engine.facts.select { |f| f.type == :alert }
|
|
601
|
+
|
|
602
|
+
# Exact threshold
|
|
603
|
+
engine.add_fact(:sensor, { value: 30 })
|
|
604
|
+
engine.run
|
|
605
|
+
|
|
606
|
+
# Just below threshold
|
|
607
|
+
engine.add_fact(:sensor, { value: 29.99 })
|
|
608
|
+
engine.run
|
|
609
|
+
|
|
610
|
+
# Just above threshold
|
|
611
|
+
engine.add_fact(:sensor, { value: 30.01 })
|
|
612
|
+
engine.run
|
|
613
|
+
end
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
### 3. Test Side Effects
|
|
617
|
+
|
|
618
|
+
```ruby
|
|
619
|
+
def test_action_side_effects
|
|
620
|
+
engine = setup_engine
|
|
621
|
+
added_facts = []
|
|
622
|
+
|
|
623
|
+
rule = KBS::Rule.new("test") do |r|
|
|
624
|
+
r.conditions = [KBS::Condition.new(:trigger, {})]
|
|
625
|
+
r.action = lambda do |facts, bindings|
|
|
626
|
+
new_fact = engine.add_fact(:result, { value: 42 })
|
|
627
|
+
added_facts << new_fact
|
|
628
|
+
end
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
engine.add_rule(rule)
|
|
632
|
+
engine.add_fact(:trigger, {})
|
|
633
|
+
engine.run
|
|
634
|
+
|
|
635
|
+
assert_equal 1, added_facts.size
|
|
636
|
+
assert_equal 42, added_facts.first[:value]
|
|
637
|
+
end
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
### 4. Use Descriptive Test Names
|
|
641
|
+
|
|
642
|
+
```ruby
|
|
643
|
+
def test_high_temperature_alert_fires_when_sensor_exceeds_threshold
|
|
644
|
+
# Clear what this tests
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
def test_alert_not_sent_twice_for_same_sensor
|
|
648
|
+
# Explains the scenario
|
|
649
|
+
end
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
### 5. Setup and Teardown
|
|
653
|
+
|
|
654
|
+
```ruby
|
|
655
|
+
class TestWithSetup < Minitest::Test
|
|
656
|
+
def setup
|
|
657
|
+
@engine = setup_engine
|
|
658
|
+
@test_db = "test_#{SecureRandom.hex(8)}.db"
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
def teardown
|
|
662
|
+
@engine.close if @engine.respond_to?(:close)
|
|
663
|
+
File.delete(@test_db) if File.exist?(@test_db)
|
|
664
|
+
end
|
|
665
|
+
end
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
## Testing Checklist
|
|
669
|
+
|
|
670
|
+
- [ ] Test each rule fires with correct facts
|
|
671
|
+
- [ ] Test each rule doesn't fire without required facts
|
|
672
|
+
- [ ] Test boundary conditions
|
|
673
|
+
- [ ] Test negated conditions
|
|
674
|
+
- [ ] Test variable bindings
|
|
675
|
+
- [ ] Test rule priorities
|
|
676
|
+
- [ ] Test rule interactions
|
|
677
|
+
- [ ] Test action side effects
|
|
678
|
+
- [ ] Test persistence (if using blackboard)
|
|
679
|
+
- [ ] Measure performance
|
|
680
|
+
- [ ] Achieve high rule coverage
|
|
681
|
+
|
|
682
|
+
## Next Steps
|
|
683
|
+
|
|
684
|
+
- **[Debugging Guide](debugging.md)** - Debug failing tests
|
|
685
|
+
- **[Performance Guide](performance.md)** - Optimize slow tests
|
|
686
|
+
- **[Architecture](../architecture/index.md)** - Understand rule execution
|
|
687
|
+
- **[Examples](../examples/stock-trading.md)** - See tested examples
|
|
688
|
+
|
|
689
|
+
---
|
|
690
|
+
|
|
691
|
+
*Good tests make rule changes safe. Test each rule thoroughly.*
|