diff --git a/.babelrc b/.babelrc
deleted file mode 100644
index eaf32387..00000000
--- a/.babelrc
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "presets": ["es2015", "stage-0"]
-}
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 8a4626c2..bcd1c853 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -5,38 +5,37 @@ name: Deploy Package
on:
push:
- branches: [ master, v7 ]
+ branches: [master, v7]
jobs:
build:
-
runs-on: ubuntu-latest
strategy:
matrix:
- node-version: [14.x, 16.x, 18.x]
+ node-version: [18.x, 20.x, 22.x]
steps:
- - uses: actions/checkout@v2
- - name: Use Node.js ${{ matrix.node-version }}
- uses: actions/setup-node@v1
- with:
- node-version: ${{ matrix.node-version }}
- - run: npm install
- - run: npm run build --if-present
- - run: npm run lint
- - run: npm test
+ - uses: actions/checkout@v2
+ - name: Use Node.js ${{ matrix.node-version }}
+ uses: actions/setup-node@v1
+ with:
+ node-version: ${{ matrix.node-version }}
+ - run: npm install
+ - run: npm run build --if-present
+ - run: npm run lint
+ - run: npm test
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v2
- - uses: actions/setup-node@v1
- with:
- node-version: 18.x
- - run: npm install
- - run: npm run build --if-present
- # https://github.com/marketplace/actions/npm-publish
- - uses: JS-DevTools/npm-publish@v2
- with:
- token: ${{ secrets.NPM_TOKEN }}
+ - uses: actions/checkout@v2
+ - uses: actions/setup-node@v1
+ with:
+ node-version: 18.x
+ - run: npm install
+ - run: npm run build --if-present
+ # https://github.com/marketplace/actions/npm-publish
+ - uses: JS-DevTools/npm-publish@v2
+ with:
+ token: ${{ secrets.NPM_TOKEN }}
diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml
index 8317332a..f421888b 100644
--- a/.github/workflows/node.js.yml
+++ b/.github/workflows/node.js.yml
@@ -5,24 +5,23 @@ name: Node.js CI
on:
pull_request:
- branches: [ master, v7 ]
+ branches: [master, v7]
jobs:
build:
-
runs-on: ubuntu-latest
strategy:
matrix:
- node-version: [14.x, 16.x, 18.x]
+ node-version: [18.x, 20.x, 22.x]
steps:
- - uses: actions/checkout@v2
- - name: Use Node.js ${{ matrix.node-version }}
- uses: actions/setup-node@v1
- with:
- node-version: ${{ matrix.node-version }}
- - run: npm install
- - run: npm run build --if-present
- - run: npm run lint
- - run: npm test
+ - uses: actions/checkout@v2
+ - name: Use Node.js ${{ matrix.node-version }}
+ uses: actions/setup-node@v1
+ with:
+ node-version: ${{ matrix.node-version }}
+ - run: npm install
+ - run: npm run build --if-present
+ - run: npm run lint
+ - run: npm test
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cc9bcf97..7c9af25d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,167 +1,209 @@
#### 6.1.0 / 2021-06-03
- * engine.removeRule() now supports removing rules by name
- * Added engine.updateRule(rule)
+
+- engine.removeRule() now supports removing rules by name
+- Added engine.updateRule(rule)
#### 6.0.1 / 2021-03-09
- * Updates Typescript types to include `failureEvents` in EngineResult.
+
+- Updates Typescript types to include `failureEvents` in EngineResult.
#### 6.0.0 / 2020-12-22
- * BREAKING CHANGES
- * To continue using [selectn](https://github.com/wilmoore/selectn.js) syntax for condition `path`s, use the new `pathResolver` feature. Read more [here](./docs/rules.md#condition-helpers-custom-path-resolver). Add the following to the engine constructor:
- ```js
- const pathResolver = (object, path) => {
- return selectn(path)(object)
- }
- const engine = new Engine(rules, { pathResolver })
- ```
- (fixes #205)
- * Engine and Rule events `on('success')`, `on('failure')`, and Rule callbacks `onSuccess` and `onFailure` now honor returned promises; any event handler that returns a promise will be waited upon to resolve before engine execution continues. (fixes #235)
- * Private `rule.event` property renamed. Use `rule.getEvent()` to avoid breaking changes in the future.
- * The `success-events` fact used to store successful events has been converted to an internal data structure and will no longer appear in the almanac's facts. (fixes #187)
- * NEW FEATURES
- * Engine constructor now accepts a `pathResolver` option for resolving condition `path` properties. Read more [here](./docs/rules.md#condition-helpers-custom-path-resolver). (fixes #210)
- * Engine.run() now returns three additional data structures:
- * `failureEvents`, an array of all failed rules events. (fixes #192)
- * `results`, an array of RuleResults for each successful rule (fixes #216)
- * `failureResults`, an array of RuleResults for each failed rule
+- BREAKING CHANGES
+ - To continue using [selectn](https://github.com/wilmoore/selectn.js) syntax for condition `path`s, use the new `pathResolver` feature. Read more [here](./docs/rules.md#condition-helpers-custom-path-resolver). Add the following to the engine constructor:
+ ```js
+ const pathResolver = (object, path) => {
+ return selectn(path)(object);
+ };
+ const engine = new Engine(rules, { pathResolver });
+ ```
+ (fixes #205)
+ - Engine and Rule events `on('success')`, `on('failure')`, and Rule callbacks `onSuccess` and `onFailure` now honor returned promises; any event handler that returns a promise will be waited upon to resolve before engine execution continues. (fixes #235)
+ - Private `rule.event` property renamed. Use `rule.getEvent()` to avoid breaking changes in the future.
+ - The `success-events` fact used to store successful events has been converted to an internal data structure and will no longer appear in the almanac's facts. (fixes #187)
+- NEW FEATURES
+ - Engine constructor now accepts a `pathResolver` option for resolving condition `path` properties. Read more [here](./docs/rules.md#condition-helpers-custom-path-resolver). (fixes #210)
+ - Engine.run() now returns three additional data structures:
+ - `failureEvents`, an array of all failed rules events. (fixes #192)
+ - `results`, an array of RuleResults for each successful rule (fixes #216)
+ - `failureResults`, an array of RuleResults for each failed rule
#### 5.3.0 / 2020-12-02
- * Allow facts to have a value of `undefined`
+
+- Allow facts to have a value of `undefined`
#### 5.2.0 / 2020-11-31
- * No changes; published to correct an accidental publish of untagged alpha
+
+- No changes; published to correct an accidental publish of untagged alpha
#### 5.0.4 / 2020-09-26
- * Upgrade dependencies to latest
+
+- Upgrade dependencies to latest
#### 5.0.3 / 2020-01-26
- * Upgrade jsonpath-plus dependency, to fix inconsistent scalar results (#175)
+
+- Upgrade jsonpath-plus dependency, to fix inconsistent scalar results (#175)
#### 5.0.2 / 2020-01-18
-* BUGFIX: Add missing `DEBUG` log for almanac.addRuntimeFact()
+
+- BUGFIX: Add missing `DEBUG` log for almanac.addRuntimeFact()
#### 5.0.1 / 2020-01-18
-* BUGFIX: `DEBUG` envs works with cookies disables
+
+- BUGFIX: `DEBUG` envs works with cookies disables
#### 5.0.0 / 2019-11-29
- * BREAKING CHANGES
- * Rule conditions' `path` property is now interpreted using [json-path](https://goessner.net/articles/JsonPath/)
- * To continue using the old syntax (provided via [selectn](https://github.com/wilmoore/selectn.js)), `npm install selectn` as a direct dependency, and `json-rules-engine` will continue to interpret legacy paths this way.
- * Any path starting with `$` will be assumed to use `json-path` syntax
+
+- BREAKING CHANGES
+ - Rule conditions' `path` property is now interpreted using [json-path](https://goessner.net/articles/JsonPath/)
+ - To continue using the old syntax (provided via [selectn](https://github.com/wilmoore/selectn.js)), `npm install selectn` as a direct dependency, and `json-rules-engine` will continue to interpret legacy paths this way.
+ - Any path starting with `$` will be assumed to use `json-path` syntax
#### 4.1.0 / 2019-09-27
- * Export Typescript definitions (@brianphillips)
+
+- Export Typescript definitions (@brianphillips)
#### 4.0.0 / 2019-08-22
- * BREAKING CHANGES
- * `engine.run()` now returns a hash of events and almanac: `{ events: [], almanac: Almanac instance }`. Previously in v3, the `run()` returned the `events` array.
- * For example, `const events = await engine.run()` under v3 will need to be changed to `const { events } = await engine.run()` under v4.
+
+- BREAKING CHANGES
+ - `engine.run()` now returns a hash of events and almanac: `{ events: [], almanac: Almanac instance }`. Previously in v3, the `run()` returned the `events` array.
+ - For example, `const events = await engine.run()` under v3 will need to be changed to `const { events } = await engine.run()` under v4.
#### 3.1.0 / 2019-07-19
- * Feature: `rule.setName()` and `ruleResult.name`
+
+- Feature: `rule.setName()` and `ruleResult.name`
#### 3.0.3 / 2019-07-15
- * Fix "localStorage.debug" not working in browsers
+
+- Fix "localStorage.debug" not working in browsers
#### 3.0.2 / 2019-05-23
- * Fix "process" not defined error in browsers lacking node.js global shims
+
+- Fix "process" not defined error in browsers lacking node.js global shims
#### 3.0.0 / 2019-05-17
- * BREAKING CHANGES
- * Previously all conditions with undefined facts would resolve false. With this change, undefined facts values are treated as `undefined`.
- * Greatly improved performance of `allowUndefinedfacts = true` engine option
- * Reduce package bundle size by ~40%
+
+- BREAKING CHANGES
+ - Previously all conditions with undefined facts would resolve false. With this change, undefined facts values are treated as `undefined`.
+- Greatly improved performance of `allowUndefinedfacts = true` engine option
+- Reduce package bundle size by ~40%
#### 2.3.5 / 2019-04-26
- * Replace debug with vanilla console.log
+
+- Replace debug with vanilla console.log
#### 2.3.4 / 2019-04-26
- * Use Array.isArray instead of instanceof to test Array parameters to address edge cases
+
+- Use Array.isArray instead of instanceof to test Array parameters to address edge cases
#### 2.3.3 / 2019-04-23
- * Fix rules cache not clearing after removeRule()
+
+- Fix rules cache not clearing after removeRule()
#### 2.3.2 / 2018-12-28
- * Upgrade all dependencies to latest
+
+- Upgrade all dependencies to latest
#### 2.3.1 / 2018-12-03
- * IE8 compatibility: replace Array.forEach with for loop (@knalbandianbrightgrove)
+
+- IE8 compatibility: replace Array.forEach with for loop (@knalbandianbrightgrove)
#### 2.3.0 / 2018-05-03
- * Engine.removeFact() - removes fact from the engine (@SaschaDeWaal)
- * Engine.removeRule() - removes rule from the engine (@SaschaDeWaal)
- * Engine.removeOperator() - removes operator from the engine (@SaschaDeWaal)
+
+- Engine.removeFact() - removes fact from the engine (@SaschaDeWaal)
+- Engine.removeRule() - removes rule from the engine (@SaschaDeWaal)
+- Engine.removeOperator() - removes operator from the engine (@SaschaDeWaal)
#### 2.2.0 / 2018-04-19
- * Performance: Constant facts now perform 18-26X better
- * Performance: Removes await/async transpilation and json.stringify calls, significantly improving overall performance
+
+- Performance: Constant facts now perform 18-26X better
+- Performance: Removes await/async transpilation and json.stringify calls, significantly improving overall performance
#### 2.1.0 / 2018-02-19
- * Publish dist updates for 2.0.3
+
+- Publish dist updates for 2.0.3
#### 2.0.3 / 2018-01-29
- * Add factResult and result to the JSON generated for Condition (@bjacobso)
+
+- Add factResult and result to the JSON generated for Condition (@bjacobso)
#### 2.0.2 / 2017-07-24
- * Bugfix IE8 support
+
+- Bugfix IE8 support
#### 2.0.1 / 2017-07-05
- * Bugfix rule result serialization
+
+- Bugfix rule result serialization
#### 2.0.0 / 2017-04-21
- * Publishing 2.0.0
+
+- Publishing 2.0.0
#### 2.0.0-beta2 / 2017-04-10
- * Fix fact path object checking to work with objects that have prototypes (lodash isObjectLike instead of isPlainObject)
+
+- Fix fact path object checking to work with objects that have prototypes (lodash isObjectLike instead of isPlainObject)
#### 2.0.0-beta1 / 2017-04-09
- * Add rule results
- * Document fact .path ability to parse properties containing dots
- * Bump dependencies
- * BREAKING CHANGES
- * `engine.on('failure', (rule, almanac))` is now `engine.on('failure', (event, almanac, ruleResult))`
- * `engine.on(eventType, (eventParams, engine))` is now `engine.on(eventType, (eventParams, almanac, ruleResult))`
+
+- Add rule results
+- Document fact .path ability to parse properties containing dots
+- Bump dependencies
+- BREAKING CHANGES
+ - `engine.on('failure', (rule, almanac))` is now `engine.on('failure', (event, almanac, ruleResult))`
+ - `engine.on(eventType, (eventParams, engine))` is now `engine.on(eventType, (eventParams, almanac, ruleResult))`
#### 1.5.1 / 2017-03-19
- * Bugfix almanac.factValue skipping interpreting condition "path" for cached facts
+
+- Bugfix almanac.factValue skipping interpreting condition "path" for cached facts
#### 1.5.0 / 2017-03-12
- * Add fact comparison conditions
+
+- Add fact comparison conditions
#### 1.4.0 / 2017-01-23
- * Add `allowUndefinedFacts` engine option
+
+- Add `allowUndefinedFacts` engine option
#### 1.3.1 / 2017-01-16
- * Bump object-hash dependency to latest
+
+- Bump object-hash dependency to latest
#### 1.3.0 / 2016-10-24
- * Rule event emissions
- * Rule chaining
+
+- Rule event emissions
+- Rule chaining
#### 1.2.1 / 2016-10-22
- * Use Array.indexOf instead of Array.includes for older node version compatibility
+
+- Use Array.indexOf instead of Array.includes for older node version compatibility
#### 1.2.0 / 2016-09-13
- * Fact path support
+
+- Fact path support
#### 1.1.0 / 2016-09-11
- * Custom operator support
+
+- Custom operator support
#### 1.0.4 / 2016-06-18
- * fix issue #6; runtime facts unique to each run()
+
+- fix issue #6; runtime facts unique to each run()
#### 1.0.3 / 2016-06-15
- * fix issue #5; dependency error babel-core/register
+
+- fix issue #5; dependency error babel-core/register
#### 1.0.0 / 2016-05-01
- * api stable; releasing 1.0
- * engine.run() now returns triggered events
+
+- api stable; releasing 1.0
+- engine.run() now returns triggered events
#### 1.0.0-beta10 / 2016-04-16
- * Completed the 'fact-dependecy' advanced example
- * Updated addFact and addRule engine methods to return 'this' for easy chaining
+
+- Completed the 'fact-dependecy' advanced example
+- Updated addFact and addRule engine methods to return 'this' for easy chaining
#### 1.0.0-beta9 / 2016-04-11
- * Completed the 'basic' example
- * [BREAKING CHANGE] update engine.on('success') and engine.on('failure') to pass the current almanac instance as the second argument, rather than the engine
+
+- Completed the 'basic' example
+- [BREAKING CHANGE] update engine.on('success') and engine.on('failure') to pass the current almanac instance as the second argument, rather than the engine
diff --git a/README.md b/README.md
index 77cfcdf1..90e107a4 100644
--- a/README.md
+++ b/README.md
@@ -8,31 +8,31 @@
A rules engine expressed in JSON
-* [Synopsis](#synopsis)
-* [Features](#features)
-* [Installation](#installation)
-* [Docs](#docs)
-* [Examples](#examples)
-* [Basic Example](#basic-example)
-* [Advanced Example](#advanced-example)
-* [Debugging](#debugging)
- * [Node](#node)
- * [Browser](#browser)
-* [Related Projects](#related-projects)
-* [License](#license)
+- [Synopsis](#synopsis)
+- [Features](#features)
+- [Installation](#installation)
+- [Docs](#docs)
+- [Examples](#examples)
+- [Basic Example](#basic-example)
+- [Advanced Example](#advanced-example)
+- [Debugging](#debugging)
+ - [Node](#node)
+ - [Browser](#browser)
+- [Related Projects](#related-projects)
+- [License](#license)
## Synopsis
-```json-rules-engine``` is a powerful, lightweight rules engine. Rules are composed of simple json structures, making them human readable and easy to persist.
+`json-rules-engine` is a powerful, lightweight rules engine. Rules are composed of simple json structures, making them human readable and easy to persist.
## Features
-* Rules expressed in simple, easy to read JSON
-* Full support for ```ALL``` and ```ANY``` boolean operators, including recursive nesting
-* Fast by default, faster with configuration; priority levels and cache settings for fine tuning performance
-* Secure; no use of eval()
-* Isomorphic; runs in node and browser
-* Lightweight & extendable; 17kb gzipped w/few dependencies
+- Rules expressed in simple, easy to read JSON
+- Full support for `ALL` and `ANY` boolean operators, including recursive nesting
+- Fast by default, faster with configuration; priority levels and cache settings for fine tuning performance
+- Secure; no use of eval()
+- Isomorphic; runs in node and browser
+- Lightweight & extendable; 17kb gzipped w/few dependencies
## Installation
@@ -56,47 +56,56 @@ See the [Examples](./examples), which demonstrate the major features and capabil
This example demonstrates an engine for detecting whether a basketball player has fouled out (a player who commits five personal fouls over the course of a 40-minute game, or six in a 48-minute game, fouls out).
```js
-const { Engine } = require('json-rules-engine')
-
+const { Engine } = require("json-rules-engine");
/**
* Setup a new engine
*/
-let engine = new Engine()
+let engine = new Engine();
// define a rule for detecting the player has exceeded foul limits. Foul out any player who:
// (has committed 5 fouls AND game is 40 minutes) OR (has committed 6 fouls AND game is 48 minutes)
engine.addRule({
conditions: {
- any: [{
- all: [{
- fact: 'gameDuration',
- operator: 'equal',
- value: 40
- }, {
- fact: 'personalFoulCount',
- operator: 'greaterThanInclusive',
- value: 5
- }]
- }, {
- all: [{
- fact: 'gameDuration',
- operator: 'equal',
- value: 48
- }, {
- fact: 'personalFoulCount',
- operator: 'greaterThanInclusive',
- value: 6
- }]
- }]
+ any: [
+ {
+ all: [
+ {
+ fact: "gameDuration",
+ operator: "equal",
+ value: 40,
+ },
+ {
+ fact: "personalFoulCount",
+ operator: "greaterThanInclusive",
+ value: 5,
+ },
+ ],
+ },
+ {
+ all: [
+ {
+ fact: "gameDuration",
+ operator: "equal",
+ value: 48,
+ },
+ {
+ fact: "personalFoulCount",
+ operator: "greaterThanInclusive",
+ value: 6,
+ },
+ ],
+ },
+ ],
},
- event: { // define the event to fire when the conditions evaluate truthy
- type: 'fouledOut',
+ event: {
+ // define the event to fire when the conditions evaluate truthy
+ type: "fouledOut",
params: {
- message: 'Player has fouled out!'
- }
- }
-})
+ message: "Player has fouled out!",
+ },
+ },
+});
/**
* Define facts the engine will use to evaluate the conditions above.
@@ -104,15 +113,13 @@ engine.addRule({
*/
let facts = {
personalFoulCount: 6,
- gameDuration: 40
-}
+ gameDuration: 40,
+};
// Run the engine to evaluate
-engine
- .run(facts)
- .then(({ events }) => {
- events.map(event => console.log(event.params.message))
- })
+engine.run(facts).then(({ events }) => {
+ events.map((event) => console.log(event.params.message));
+});
/*
* Output:
@@ -127,20 +134,20 @@ This is available in the [examples](./examples/02-nested-boolean-logic.js)
This example demonstates an engine for identifying employees who work for Microsoft and are taking Christmas day off.
-This demonstrates an engine which uses asynchronous fact data.
+This demonstrates an engine which uses asynchronous fact data.
Fact information is loaded via API call during runtime, and the results are cached and recycled for all 3 conditions.
It also demonstates use of the condition _path_ feature to reference properties of objects returned by facts.
```js
-const { Engine } = require('json-rules-engine')
+const { Engine } = require("json-rules-engine");
// example client for making asynchronous requests to an api, database, etc
-import apiClient from './account-api-client'
+import apiClient from "./account-api-client";
/**
* Setup a new engine
*/
-let engine = new Engine()
+let engine = new Engine();
/**
* Rule for identifying microsoft employees taking pto on christmas
@@ -150,31 +157,35 @@ let engine = new Engine()
*/
let microsoftRule = {
conditions: {
- all: [{
- fact: 'account-information',
- operator: 'equal',
- value: 'microsoft',
- path: '$.company' // access the 'company' property of "account-information"
- }, {
- fact: 'account-information',
- operator: 'in',
- value: ['active', 'paid-leave'], // 'status' can be active or paid-leave
- path: '$.status' // access the 'status' property of "account-information"
- }, {
- fact: 'account-information',
- operator: 'contains', // the 'ptoDaysTaken' property (an array) must contain '2016-12-25'
- value: '2016-12-25',
- path: '$.ptoDaysTaken' // access the 'ptoDaysTaken' property of "account-information"
- }]
+ all: [
+ {
+ fact: "account-information",
+ operator: "equal",
+ value: "microsoft",
+ path: "$.company", // access the 'company' property of "account-information"
+ },
+ {
+ fact: "account-information",
+ operator: "in",
+ value: ["active", "paid-leave"], // 'status' can be active or paid-leave
+ path: "$.status", // access the 'status' property of "account-information"
+ },
+ {
+ fact: "account-information",
+ operator: "contains", // the 'ptoDaysTaken' property (an array) must contain '2016-12-25'
+ value: "2016-12-25",
+ path: "$.ptoDaysTaken", // access the 'ptoDaysTaken' property of "account-information"
+ },
+ ],
},
event: {
- type: 'microsoft-christmas-pto',
+ type: "microsoft-christmas-pto",
params: {
- message: 'current microsoft employee taking christmas day off'
- }
- }
-}
-engine.addRule(microsoftRule)
+ message: "current microsoft employee taking christmas day off",
+ },
+ },
+};
+engine.addRule(microsoftRule);
/**
* 'account-information' fact executes an api call and retrieves account data, feeding the results
@@ -182,21 +193,20 @@ engine.addRule(microsoftRule)
* requiring this data, only ONE api call is made. This results in much more efficient runtime performance
* and fewer network requests.
*/
-engine.addFact('account-information', function (params, almanac) {
- console.log('loading account information...')
- return almanac.factValue('accountId')
- .then((accountId) => {
- return apiClient.getAccountInformation(accountId)
- })
-})
+engine.addFact("account-information", function (params, almanac) {
+ console.log("loading account information...");
+ return almanac.factValue("accountId").then((accountId) => {
+ return apiClient.getAccountInformation(accountId);
+ });
+});
// define fact(s) known at runtime
-let facts = { accountId: 'lincoln' }
-engine
- .run(facts)
- .then(({ events }) => {
- console.log(facts.accountId + ' is a ' + events.map(event => event.params.message))
- })
+let facts = { accountId: "lincoln" };
+engine.run(facts).then(({ events }) => {
+ console.log(
+ facts.accountId + " is a " + events.map((event) => event.params.message),
+ );
+});
/*
* OUTPUT:
@@ -219,9 +229,10 @@ DEBUG=json-rules-engine
```
### Browser
+
```js
// set debug flag in local storage & refresh page to see console output
-localStorage.debug = 'json-rules-engine'
+localStorage.debug = "json-rules-engine";
```
## Related Projects
@@ -230,6 +241,6 @@ https://github.com/vinzdeveloper/json-rule-editor - configuration ui for json-ru
-
## License
+
[ISC](./LICENSE)
diff --git a/docs/almanac.md b/docs/almanac.md
index d0c38264..8028969f 100644
--- a/docs/almanac.md
+++ b/docs/almanac.md
@@ -1,37 +1,37 @@
# Almanac
-* [Overview](#overview)
-* [Methods](#methods)
- * [almanac.factValue(Fact fact, Object params, String path) -> Promise](#almanacfactvaluefact-fact-object-params-string-path---promise)
- * [almanac.addFact(String id, Function [definitionFunc], Object [options])](#almanacaddfactstring-id-function-definitionfunc-object-options)
- * [almanac.addRuntimeFact(String factId, Mixed value)](#almanacaddruntimefactstring-factid-mixed-value)
- * [almanac.getEvents(String outcome) -> Events[]](#almanacgeteventsstring-outcome---events)
- * [almanac.getResults() -> RuleResults[]](#almanacgetresults---ruleresults)
-* [Common Use Cases](#common-use-cases)
- * [Fact dependencies](#fact-dependencies)
- * [Retrieve fact values when handling events](#retrieve-fact-values-when-handling-events)
- * [Rule Chaining](#rule-chaining)
+- [Overview](#overview)
+- [Methods](#methods)
+ - [almanac.factValue(Fact fact, Object params, String path) -> Promise](#almanacfactvaluefact-fact-object-params-string-path---promise)
+ - [almanac.addFact(String id, Function [definitionFunc], Object [options])](#almanacaddfactstring-id-function-definitionfunc-object-options)
+ - [almanac.addRuntimeFact(String factId, Mixed value)](#almanacaddruntimefactstring-factid-mixed-value)
+ - [almanac.getEvents(String outcome) -> Events[]](#almanacgeteventsstring-outcome---events)
+ - [almanac.getResults() -> RuleResults[]](#almanacgetresults---ruleresults)
+- [Common Use Cases](#common-use-cases)
+ - [Fact dependencies](#fact-dependencies)
+ - [Retrieve fact values when handling events](#retrieve-fact-values-when-handling-events)
+ - [Rule Chaining](#rule-chaining)
## Overview
-An almanac collects facts through an engine run cycle. As the engine computes fact values,
-the results are stored in the almanac and cached. If the engine detects a fact computation has
-been previously computed, it reuses the cached result from the almanac. Every time ```engine.run()``` is invoked,
+An almanac collects facts through an engine run cycle. As the engine computes fact values,
+the results are stored in the almanac and cached. If the engine detects a fact computation has
+been previously computed, it reuses the cached result from the almanac. Every time `engine.run()` is invoked,
a new almanac is instantiated.
The almanac for the current engine run is available as arguments passed to the fact evaluation methods and
- to the engine ```success``` event. The almanac may be used to define additional facts during runtime.
+to the engine `success` event. The almanac may be used to define additional facts during runtime.
## Methods
### almanac.factValue(Fact fact, Object params, String path) -> Promise
-Computes the value of the provided fact + params. If "path" is provided, it will be used as a [json-path](https://goessner.net/articles/JsonPath/) accessor on the fact's return object.
+Computes the value of the provided fact + params. If "path" is provided, it will be used as a [json-path](https://goessner.net/articles/JsonPath/) accessor on the fact's return object.
```js
almanac
- .factValue('account-information', { accountId: 1 }, '.balance')
- .then( value => console.log(value))
+ .factValue("account-information", { accountId: 1 }, ".balance")
+ .then((value) => console.log(value));
```
### almanac.addFact(String id, Function [definitionFunc], Object [options])
@@ -40,26 +40,30 @@ Sets a fact in the almanac. Used in conjunction with rule and engine event emiss
```js
// constant facts:
-engine.addFact('speed-of-light', 299792458)
+engine.addFact("speed-of-light", 299792458);
// facts computed via function
-engine.addFact('account-type', function getAccountType(params, almanac) {
+engine.addFact("account-type", function getAccountType(params, almanac) {
// ...
-})
+});
// facts with options:
-engine.addFact('account-type', function getAccountType(params, almanac) {
- // ...
-}, { cache: false, priority: 500 })
+engine.addFact(
+ "account-type",
+ function getAccountType(params, almanac) {
+ // ...
+ },
+ { cache: false, priority: 500 },
+);
```
### almanac.addRuntimeFact(String factId, Mixed value)
**Deprecated** Use `almanac.addFact` instead
-Sets a constant fact mid-run. Often used in conjunction with rule and engine event emissions.
+Sets a constant fact mid-run. Often used in conjunction with rule and engine event emissions.
```js
-almanac.addRuntimeFact('account-id', 1)
+almanac.addRuntimeFact("account-id", 1);
```
### almanac.getEvents(String outcome) -> Events[]
@@ -67,11 +71,11 @@ almanac.addRuntimeFact('account-id', 1)
Returns events by outcome ("success" or "failure") for the current engine run()
```js
-almanac.getEvents() // all events for every rule evaluated thus far
+almanac.getEvents(); // all events for every rule evaluated thus far
-almanac.getEvents('success') // array of success events
+almanac.getEvents("success"); // array of success events
-almanac.getEvents('failure') // array of failure events
+almanac.getEvents("failure"); // array of failure events
```
### almanac.getResults() -> RuleResults[]
@@ -79,20 +83,20 @@ almanac.getEvents('failure') // array of failure events
Returns [rule results](./rules#rule-results) for the current engine run()
```js
-almanac.getResults()
+almanac.getResults();
```
## Common Use Cases
### Fact dependencies
-The most common use of the almanac is to access data computed by other facts during runtime. This allows
+The most common use of the almanac is to access data computed by other facts during runtime. This allows
leveraging the engine's caching mechanisms to design more efficient rules.
The [fact-dependency](../examples/04-fact-dependency.js) example demonstrates a real world application of this technique.
-For example, say there were two facts: _is-funded-account_ and _account-balance_. Both facts depend on the same _account-information_ data set.
-Using the Almanac, each fact can be defined to call a **base** fact responsible for loading the data. This causes the engine
+For example, say there were two facts: _is-funded-account_ and _account-balance_. Both facts depend on the same _account-information_ data set.
+Using the Almanac, each fact can be defined to call a **base** fact responsible for loading the data. This causes the engine
to make the API call for loading account information only once per account.
```js
@@ -146,10 +150,10 @@ engine.run({ accountId: 1 })
### Retrieve fact values when handling events
-When a rule evalutes truthy and its ```event``` is called, new facts may be defined by the event handler.
- Note that with this technique, the rule priority becomes important; if a rule is expected to
- define a fact value, it's important that rule be run prior to other rules that reference the fact. To
- learn more about setting rule priorities, see the [rule documentation](./rules.md).
+When a rule evalutes truthy and its `event` is called, new facts may be defined by the event handler.
+Note that with this technique, the rule priority becomes important; if a rule is expected to
+define a fact value, it's important that rule be run prior to other rules that reference the fact. To
+learn more about setting rule priorities, see the [rule documentation](./rules.md).
```js
engine.on('success', (event, almanac) => {
@@ -166,7 +170,7 @@ engine.on('success', (event, almanac) => {
### Rule Chaining
The `almanac.addRuntimeFact()` method may be used in conjunction with event emissions to
-set fact values during runtime, effectively enabling _rule-chaining_. Note that ordering
+set fact values during runtime, effectively enabling _rule-chaining_. Note that ordering
of rule execution is enabled via the `priority` option, and is crucial component to propertly
configuring rule chaining.
diff --git a/docs/engine.md b/docs/engine.md
index b944adc6..13c61934 100644
--- a/docs/engine.md
+++ b/docs/engine.md
@@ -2,24 +2,24 @@
The Engine stores and executes rules, emits events, and maintains state.
-* [Methods](#methods)
- * [constructor([Array rules], Object [options])](#constructorarray-rules-object-options)
- * [Options](#options)
- * [engine.addFact(String id, Function [definitionFunc], Object [options])](#engineaddfactstring-id-function-definitionfunc-object-options)
- * [engine.removeFact(String id)](#engineremovefactstring-id)
- * [engine.addRule(Rule instance|Object options)](#engineaddrulerule-instanceobject-options)
- * [engine.updateRule(Rule instance|Object options)](#engineupdaterulerule-instanceobject-options)
- * [engine.removeRule(Rule instance | String ruleId)](#engineremoverulerule-instance)
- * [engine.addOperator(String operatorName, Function evaluateFunc(factValue, jsonValue))](#engineaddoperatorstring-operatorname-function-evaluatefuncfactvalue-jsonvalue)
- * [engine.removeOperator(String operatorName)](#engineremoveoperatorstring-operatorname)
- * [engine.addOperatorDecorator(String decoratorName, Function evaluateFunc(factValue, jsonValue, next))](#engineaddoperatordecoratorstring-decoratorname-function-evaluatefuncfactvalue-jsonvalue-next)
- * [engine.removeOperatorDecorator(String decoratorName)](#engineremoveoperatordecoratorstring-decoratorname)
- * [engine.setCondition(String name, Object conditions)](#enginesetconditionstring-name-object-conditions)
- * [engine.removeCondition(String name)](#engineremovecondtionstring-name)
- * [engine.run([Object facts], [Object options]) -> Promise ({ events: [], failureEvents: [], almanac: Almanac, results: [], failureResults: []})](#enginerunobject-facts-object-options---promise--events--failureevents--almanac-almanac-results--failureresults-)
- * [engine.stop() -> Engine](#enginestop---engine)
- * [engine.on('success', Function(Object event, Almanac almanac, RuleResult ruleResult))](#engineonsuccess-functionobject-event-almanac-almanac-ruleresult-ruleresult)
- * [engine.on('failure', Function(Object event, Almanac almanac, RuleResult ruleResult))](#engineonfailure-functionobject-event-almanac-almanac-ruleresult-ruleresult)
+- [Methods](#methods)
+ - [constructor([Array rules], Object [options])](#constructorarray-rules-object-options)
+ - [Options](#options)
+ - [engine.addFact(String id, Function [definitionFunc], Object [options])](#engineaddfactstring-id-function-definitionfunc-object-options)
+ - [engine.removeFact(String id)](#engineremovefactstring-id)
+ - [engine.addRule(Rule instance|Object options)](#engineaddrulerule-instanceobject-options)
+ - [engine.updateRule(Rule instance|Object options)](#engineupdaterulerule-instanceobject-options)
+ - [engine.removeRule(Rule instance | String ruleId)](#engineremoverulerule-instance)
+ - [engine.addOperator(String operatorName, Function evaluateFunc(factValue, jsonValue))](#engineaddoperatorstring-operatorname-function-evaluatefuncfactvalue-jsonvalue)
+ - [engine.removeOperator(String operatorName)](#engineremoveoperatorstring-operatorname)
+ - [engine.addOperatorDecorator(String decoratorName, Function evaluateFunc(factValue, jsonValue, next))](#engineaddoperatordecoratorstring-decoratorname-function-evaluatefuncfactvalue-jsonvalue-next)
+ - [engine.removeOperatorDecorator(String decoratorName)](#engineremoveoperatordecoratorstring-decoratorname)
+ - [engine.setCondition(String name, Object conditions)](#enginesetconditionstring-name-object-conditions)
+ - [engine.removeCondition(String name)](#engineremovecondtionstring-name)
+ - [engine.run([Object facts], [Object options]) -> Promise ({ events: [], failureEvents: [], almanac: Almanac, results: [], failureResults: []})](#enginerunobject-facts-object-options---promise--events--failureevents--almanac-almanac-results--failureresults-)
+ - [engine.stop() -> Engine](#enginestop---engine)
+ - [engine.on('success', Function(Object event, Almanac almanac, RuleResult ruleResult))](#engineonsuccess-functionobject-event-almanac-almanac-ruleresult-ruleresult)
+ - [engine.on('failure', Function(Object event, Almanac almanac, RuleResult ruleResult))](#engineonfailure-functionobject-event-almanac-almanac-ruleresult-ruleresult)
## Methods
@@ -44,8 +44,8 @@ let engine = new Engine([Array rules], options)
#### Options
`allowUndefinedFacts` - By default, when a running engine encounters an undefined fact,
-an exception is thrown. Turning this option on will cause the engine to treat
-undefined facts as `undefined`. (default: false)
+an exception is thrown. Turning this option on will cause the engine to treat
+undefined facts as `undefined`. (default: false)
`allowUndefinedConditions` - By default, when a running engine encounters a
condition reference that cannot be resolved an exception is thrown. Turning
@@ -60,85 +60,89 @@ as failed conditions. (default: false)
```js
// constant facts:
-engine.addFact('speed-of-light', 299792458)
+engine.addFact("speed-of-light", 299792458);
// facts computed via function
-engine.addFact('account-type', function getAccountType(params, almanac) {
+engine.addFact("account-type", function getAccountType(params, almanac) {
// ...
-})
+});
// facts with options:
-engine.addFact('account-type', function getAccountType(params, almanac) {
- // ...
-}, { cache: false, priority: 500 })
+engine.addFact(
+ "account-type",
+ function getAccountType(params, almanac) {
+ // ...
+ },
+ { cache: false, priority: 500 },
+);
```
### engine.removeFact(String id)
```js
-engine.addFact('speed-of-light', 299792458)
+engine.addFact("speed-of-light", 299792458);
// removes the fact
-engine.removeFact('speed-of-light')
+engine.removeFact("speed-of-light");
```
### engine.addRule(Rule instance|Object options)
-Adds a rule to the engine. The engine will execute the rule upon the next ```run()```
+Adds a rule to the engine. The engine will execute the rule upon the next `run()`
```js
-let Rule = require('json-rules-engine').Rule
+let Rule = require("json-rules-engine").Rule;
// via rule properties:
engine.addRule({
conditions: {},
event: {},
- priority: 1, // optional, default: 1
+ priority: 1, // optional, default: 1
onSuccess: function (event, almanac) {}, // optional
onFailure: function (event, almanac) {}, // optional
-})
+});
// or rule instance:
-let rule = new Rule()
-engine.addRule(rule)
+let rule = new Rule();
+engine.addRule(rule);
```
- ### engine.removeRule(Rule instance | Any ruleName) -> Boolean
+### engine.removeRule(Rule instance | Any ruleName) -> Boolean
- Removes a rule from the engine, either by passing a rule object or a rule name. When removing by rule name, all rules matching the provided name will be removed.
+Removes a rule from the engine, either by passing a rule object or a rule name. When removing by rule name, all rules matching the provided name will be removed.
- Method returns true when rule was successfully remove, or false when not found.
+Method returns true when rule was successfully remove, or false when not found.
```javascript
// adds a rule
-let rule = new Rule()
-engine.addRule(rule)
+let rule = new Rule();
+engine.addRule(rule);
//remove it
-engine.removeRule(rule)
+engine.removeRule(rule);
//or
-engine.removeRule(rule.name)
+engine.removeRule(rule.name);
```
- ### engine.updateRule(Rule instance|Object options)
+### engine.updateRule(Rule instance|Object options)
- Updates a rule in the engine.
+Updates a rule in the engine.
```javascript
// adds a rule
-let rule = new Rule()
-engine.addRule(rule)
+let rule = new Rule();
+engine.addRule(rule);
// change rule condition
-rule.conditions.all = []
+rule.conditions.all = [];
//update it in the engine
-engine.updateRule(rule)
+engine.updateRule(rule);
```
### engine.addOperator(String operatorName, Function evaluateFunc(factValue, jsonValue))
-Adds a custom operator to the engine. For situations that require going beyond the generic, built-in operators (`equal`, `greaterThan`, etc).
+Adds a custom operator to the engine. For situations that require going beyond the generic, built-in operators (`equal`, `greaterThan`, etc).
```js
/*
@@ -168,19 +172,17 @@ let rule = new Rule(
See the [operator example](../examples/06-custom-operators.js)
-
-
### engine.removeOperator(String operatorName)
Removes a operator from the engine
```javascript
-engine.addOperator('startsWithLetter', (factValue, jsonValue) => {
- if (!factValue.length) return false
- return factValue[0].toLowerCase() === jsonValue.toLowerCase()
-})
+engine.addOperator("startsWithLetter", (factValue, jsonValue) => {
+ if (!factValue.length) return false;
+ return factValue[0].toLowerCase() === jsonValue.toLowerCase();
+});
-engine.removeOperator('startsWithLetter');
+engine.removeOperator("startsWithLetter");
```
### engine.addOperatorDecorator(String decoratorName, Function evaluateFunc(factValue, jsonValue, next))
@@ -220,23 +222,21 @@ let rule = new Rule(
See the [operator decorator example](../examples/13-using-operator-decorators.js)
-
-
### engine.removeOperatorDecorator(String decoratorName)
Removes a operator decorator from the engine
```javascript
-engine.addOperatorDecorator('first', (factValue, jsonValue, next) => {
- if (!factValue.length) return false
- return next(factValue[0], jsonValue)
-})
+engine.addOperatorDecorator("first", (factValue, jsonValue, next) => {
+ if (!factValue.length) return false;
+ return next(factValue[0], jsonValue);
+});
-engine.addOperatorDecorator('caseInsensitive', (factValue, jsonValue, next) => {
- return next(factValue.toLowerCase(), jsonValue.toLowerCase())
-})
+engine.addOperatorDecorator("caseInsensitive", (factValue, jsonValue, next) => {
+ return next(factValue.toLowerCase(), jsonValue.toLowerCase());
+});
-engine.removeOperator('first');
+engine.removeOperator("first");
```
### engine.setCondition(String name, Object conditions)
@@ -244,41 +244,40 @@ engine.removeOperator('first');
Adds or updates a condition to the engine. Rules may include references to this condition. Conditions must start with `all`, `any`, `not`, or reference a condition.
```javascript
-engine.setCondition('validLogin', {
+engine.setCondition("validLogin", {
all: [
{
- operator: 'notEqual',
- fact: 'loginToken',
- value: null
+ operator: "notEqual",
+ fact: "loginToken",
+ value: null,
},
{
- operator: 'greaterThan',
- fact: 'loginToken',
- path: '$.expirationTime',
- value: { fact: 'now' }
- }
- ]
+ operator: "greaterThan",
+ fact: "loginToken",
+ path: "$.expirationTime",
+ value: { fact: "now" },
+ },
+ ],
});
engine.addRule({
condtions: {
all: [
{
- condition: 'validLogin'
+ condition: "validLogin",
},
{
- operator: 'contains',
- fact: 'loginToken',
- path: '$.role',
- value: 'admin'
- }
- ]
+ operator: "contains",
+ fact: "loginToken",
+ path: "$.role",
+ value: "admin",
+ },
+ ],
},
event: {
- type: 'AdminAccessAllowed'
- }
-})
-
+ type: "AdminAccessAllowed",
+ },
+});
```
### engine.removeCondition(String name)
@@ -286,45 +285,45 @@ engine.addRule({
Removes the condition that was previously added.
```javascript
-engine.setCondition('validLogin', {
+engine.setCondition("validLogin", {
all: [
{
- operator: 'notEqual',
- fact: 'loginToken',
- value: null
+ operator: "notEqual",
+ fact: "loginToken",
+ value: null,
},
{
- operator: 'greaterThan',
- fact: 'loginToken',
- path: '$.expirationTime',
- value: { fact: 'now' }
- }
- ]
+ operator: "greaterThan",
+ fact: "loginToken",
+ path: "$.expirationTime",
+ value: { fact: "now" },
+ },
+ ],
});
-engine.removeCondition('validLogin');
+engine.removeCondition("validLogin");
```
-
### engine.run([Object facts], [Object options]) -> Promise ({ events: [], failureEvents: [], almanac: Almanac, results: [], failureResults: []})
-Runs the rules engine. Returns a promise which resolves when all rules have been run.
+Runs the rules engine. Returns a promise which resolves when all rules have been run.
```js
// run the engine
-await engine.run()
+await engine.run();
// with constant facts
-await engine.run({ userId: 1 })
+await engine.run({ userId: 1 });
const {
- results, // rule results for successful rules
- failureResults, // rule results for failed rules
- events, // array of successful rule events
- failureEvents, // array of failed rule events
- almanac // Almanac instance representing the run
-} = await engine.run({ userId: 1 })
+ results, // rule results for successful rules
+ failureResults, // rule results for failed rules
+ events, // array of successful rule events
+ failureEvents, // array of failed rule events
+ almanac, // Almanac instance representing the run
+} = await engine.run({ userId: 1 });
```
+
Link to the [Almanac documentation](./almanac.md)
Optionally, you may specify a specific almanac instance via the almanac property.
@@ -334,39 +333,39 @@ Optionally, you may specify a specific almanac instance via the almanac property
const myCustomAlmanac = new CustomAlmanac();
// run the engine with the custom almanac
-await engine.run({}, { almanac: myCustomAlmanac })
+await engine.run({}, { almanac: myCustomAlmanac });
```
### engine.stop() -> Engine
-Stops the rules engine from running the next priority set of Rules. All remaining rules will be resolved as undefined,
+Stops the rules engine from running the next priority set of Rules. All remaining rules will be resolved as undefined,
and no further events emitted.
-Be aware that since rules of the *same* priority are evaluated in parallel(not series), other rules of
+Be aware that since rules of the _same_ priority are evaluated in parallel(not series), other rules of
the same priority may still emit events, even though the engine has been told to stop.
```js
-engine.stop()
+engine.stop();
```
There are two generic event emissions that trigger automatically:
-#### ```engine.on('success', Function(Object event, Almanac almanac, RuleResult ruleResult))```
+#### `engine.on('success', Function(Object event, Almanac almanac, RuleResult ruleResult))`
Fires when a rule passes. The callback will receive the event object, the current [Almanac](./almanac.md), and the [Rule Result](./rules.md#rule-results). Any promise returned by the callback will be waited on to resolve before execution continues.
```js
-engine.on('success', function(event, almanac, ruleResult) {
- console.log(event) // { type: 'my-event', params: { id: 1 }
-})
+engine.on("success", function (event, almanac, ruleResult) {
+ console.log(event); // { type: 'my-event', params: { id: 1 }
+});
```
-#### ```engine.on('failure', Function(Object event, Almanac almanac, RuleResult ruleResult))```
+#### `engine.on('failure', Function(Object event, Almanac almanac, RuleResult ruleResult))`
-Companion to 'success', except fires when a rule fails. The callback will receive the event object, the current [Almanac](./almanac.md), and the [Rule Result](./rules.md#rule-results). Any promise returned by the callback will be waited on to resolve before execution continues.
+Companion to 'success', except fires when a rule fails. The callback will receive the event object, the current [Almanac](./almanac.md), and the [Rule Result](./rules.md#rule-results). Any promise returned by the callback will be waited on to resolve before execution continues.
```js
-engine.on('failure', function(event, almanac, ruleResult) {
- console.log(event) // { type: 'my-event', params: { id: 1 }
-})
+engine.on("failure", function (event, almanac, ruleResult) {
+ console.log(event); // { type: 'my-event', params: { id: 1 }
+});
```
diff --git a/docs/facts.md b/docs/facts.md
index 588f5c4d..1f72846e 100644
--- a/docs/facts.md
+++ b/docs/facts.md
@@ -1,10 +1,10 @@
# Facts
-Facts are methods or constants registered with the engine prior to runtime and referenced within rule conditions. Each fact method should be a pure function that may return a either computed value, or promise that resolves to a computed value.
+Facts are methods or constants registered with the engine prior to runtime and referenced within rule conditions. Each fact method should be a pure function that may return a either computed value, or promise that resolves to a computed value.
As rule conditions are evaluated during runtime, they retrieve fact values dynamically and use the condition _operator_ to compare the fact result with the condition _value_.
-* [Methods](#methods)
- * [constructor(String id, Constant|Function(Object params, Almanac almanac), [Object options]) -> instance](#constructorstring-id-constantfunctionobject-params-almanac-almanac-object-options---instance)
+- [Methods](#methods)
+ - [constructor(String id, Constant|Function(Object params, Almanac almanac), [Object options]) -> instance](#constructorstring-id-constantfunctionobject-params-almanac-almanac-object-options---instance)
## Methods
@@ -12,19 +12,24 @@ As rule conditions are evaluated during runtime, they retrieve fact values dynam
```js
// constant value facts
-let fact = new Fact('apiKey', '4feca34f9d67e99b8af2')
+let fact = new Fact("apiKey", "4feca34f9d67e99b8af2");
// dynamic facts
-let fact = new Fact('account-type', (params, almanac) => {
+let fact = new Fact("account-type", (params, almanac) => {
// ...
-})
+});
// facts with options:
-engine.addFact('account-type', (params, almanac) => {
- // ...
-}, { cache: false, priority: 500 })
+engine.addFact(
+ "account-type",
+ (params, almanac) => {
+ // ...
+ },
+ { cache: false, priority: 500 },
+);
```
**options**
-* { cache: Boolean } - Sets whether the engine should cache the result of this fact. Cache key is based on the factId and 'params' passed to it. Default: *true*
-* { priority: Integer } - Sets when the fact should run in relation to other facts and conditions. The higher the priority value, the sooner the fact will run. Default: *1*
+
+- { cache: Boolean } - Sets whether the engine should cache the result of this fact. Cache key is based on the factId and 'params' passed to it. Default: _true_
+- { priority: Integer } - Sets when the fact should run in relation to other facts and conditions. The higher the priority value, the sooner the fact will run. Default: _1_
diff --git a/docs/rules.md b/docs/rules.md
index 7f6d4238..60eca0e6 100644
--- a/docs/rules.md
+++ b/docs/rules.md
@@ -1,39 +1,38 @@
-
# Rules
-Rules contain a set of _conditions_ and a single _event_. When the engine is run, each rule condition is evaluated. If the results are truthy, the rule's _event_ is triggered.
-
-* [Methods](#methods)
- * [constructor([Object options|String json])](#constructorobject-optionsstring-json)
- * [setConditions(Array conditions)](#setconditionsarray-conditions)
- * [getConditions() -> Object](#getconditions---object)
- * [setEvent(Object event)](#seteventobject-event)
- * [getEvent() -> Object](#getevent---object)
- * [setPriority(Integer priority = 1)](#setpriorityinteger-priority--1)
- * [getPriority() -> Integer](#getpriority---integer)
- * [toJSON(Boolean stringify = true)](#tojsonboolean-stringify--true)
-* [Conditions](#conditions)
- * [Basic conditions](#basic-conditions)
- * [Boolean expressions: all, any, and not](#boolean-expressions-all-any-and-not)
- * [Condition Reference](#condition-reference)
- * [Condition helpers: params](#condition-helpers-params)
- * [Condition helpers: path](#condition-helpers-path)
- * [Condition helpers: custom path resolver](#condition-helpers-custom-path-resolver)
- * [Comparing facts](#comparing-facts)
-* [Events](#events)
- * [rule.on('success', Function(Object event, Almanac almanac, RuleResult ruleResult))](#ruleonsuccess-functionobject-event-almanac-almanac-ruleresult-ruleresult)
- * [rule.on('failure', Function(Object event, Almanac almanac, RuleResult ruleResult))](#ruleonfailure-functionobject-event-almanac-almanac-ruleresult-ruleresult)
-* [Operators](#operators)
- * [String and Numeric operators:](#string-and-numeric-operators)
- * [Numeric operators:](#numeric-operators)
- * [Array operators:](#array-operators)
-* [Operator Decorators](#operator-decorators)
- * [Array decorators:](#array-decorators)
- * [Logical decorators:](#logical-decorators)
- * [Utility decorators:](#utility-decorators)
- * [Decorator composition:](#decorator-composition)
-* [Rule Results](#rule-results)
-* [Persisting](#persisting)
+Rules contain a set of _conditions_ and a single _event_. When the engine is run, each rule condition is evaluated. If the results are truthy, the rule's _event_ is triggered.
+
+- [Methods](#methods)
+ - [constructor([Object options|String json])](#constructorobject-optionsstring-json)
+ - [setConditions(Array conditions)](#setconditionsarray-conditions)
+ - [getConditions() -> Object](#getconditions---object)
+ - [setEvent(Object event)](#seteventobject-event)
+ - [getEvent() -> Object](#getevent---object)
+ - [setPriority(Integer priority = 1)](#setpriorityinteger-priority--1)
+ - [getPriority() -> Integer](#getpriority---integer)
+ - [toJSON(Boolean stringify = true)](#tojsonboolean-stringify--true)
+- [Conditions](#conditions)
+ - [Basic conditions](#basic-conditions)
+ - [Boolean expressions: all, any, and not](#boolean-expressions-all-any-and-not)
+ - [Condition Reference](#condition-reference)
+ - [Condition helpers: params](#condition-helpers-params)
+ - [Condition helpers: path](#condition-helpers-path)
+ - [Condition helpers: custom path resolver](#condition-helpers-custom-path-resolver)
+ - [Comparing facts](#comparing-facts)
+- [Events](#events)
+ - [rule.on('success', Function(Object event, Almanac almanac, RuleResult ruleResult))](#ruleonsuccess-functionobject-event-almanac-almanac-ruleresult-ruleresult)
+ - [rule.on('failure', Function(Object event, Almanac almanac, RuleResult ruleResult))](#ruleonfailure-functionobject-event-almanac-almanac-ruleresult-ruleresult)
+- [Operators](#operators)
+ - [String and Numeric operators:](#string-and-numeric-operators)
+ - [Numeric operators:](#numeric-operators)
+ - [Array operators:](#array-operators)
+- [Operator Decorators](#operator-decorators)
+ - [Array decorators:](#array-decorators)
+ - [Logical decorators:](#logical-decorators)
+ - [Utility decorators:](#utility-decorators)
+ - [Decorator composition:](#decorator-composition)
+- [Rule Results](#rule-results)
+- [Persisting](#persisting)
## Methods
@@ -46,37 +45,37 @@ let options = {
conditions: {
all: [
{
- fact: 'my-fact',
- operator: 'equal',
- value: 'some-value'
- }
- ]
+ fact: "my-fact",
+ operator: "equal",
+ value: "some-value",
+ },
+ ],
},
event: {
- type: 'my-event',
+ type: "my-event",
params: {
- customProperty: 'customValue'
- }
+ customProperty: "customValue",
+ },
},
- name: any, // optional
- priority: 1, // optional, default: 1
+ name: any, // optional
+ priority: 1, // optional, default: 1
onSuccess: function (event, almanac) {}, // optional
onFailure: function (event, almanac) {}, // optional
-}
-let rule = new Rule(options)
+};
+let rule = new Rule(options);
```
**options.conditions** : `[Object]` Rule conditions object
-**options.event** : `[Object]` Sets the `.on('success')` and `on('failure')` event argument emitted whenever the rule passes. Event objects must have a ```type``` property, and an optional ```params``` property.
+**options.event** : `[Object]` Sets the `.on('success')` and `on('failure')` event argument emitted whenever the rule passes. Event objects must have a `type` property, and an optional `params` property.
-**options.priority** : `[Number, default 1]` Dictates when rule should be run, relative to other rules. Higher priority rules are run before lower priority rules. Rules with the same priority are run in parallel. Priority must be a positive, non-zero integer.
+**options.priority** : `[Number, default 1]` Dictates when rule should be run, relative to other rules. Higher priority rules are run before lower priority rules. Rules with the same priority are run in parallel. Priority must be a positive, non-zero integer.
-**options.onSuccess** : `[Function(Object event, Almanac almanac)]` Registers callback with the rule's `on('success')` listener. The rule's `event` property and the current [Almanac](./almanac.md) are passed as arguments. Any promise returned by the callback will be waited on to resolve before execution continues.
+**options.onSuccess** : `[Function(Object event, Almanac almanac)]` Registers callback with the rule's `on('success')` listener. The rule's `event` property and the current [Almanac](./almanac.md) are passed as arguments. Any promise returned by the callback will be waited on to resolve before execution continues.
-**options.onFailure** : `[Function(Object event, Almanac almanac)]` Registers callback with the rule's `on('failure')` listener. The rule's `event` property and the current [Almanac](./almanac.md) are passed as arguments. Any promise returned by the callback will be waited on to resolve before execution continues.
+**options.onFailure** : `[Function(Object event, Almanac almanac)]` Registers callback with the rule's `on('failure')` listener. The rule's `event` property and the current [Almanac](./almanac.md) are passed as arguments. Any promise returned by the callback will be waited on to resolve before execution continues.
-**options.name** : `[Any]` A way of naming your rules, allowing them to be easily identifiable in [Rule Results](#rule-results). This is usually of type `String`, but could also be `Object`, `Array`, or `Number`. Note that the name need not be unique, and that it has no impact on execution of the rule.
+**options.name** : `[Any]` A way of naming your rules, allowing them to be easily identifiable in [Rule Results](#rule-results). This is usually of type `String`, but could also be `Object`, `Array`, or `Number`. Note that the name need not be unique, and that it has no impact on execution of the rule.
### setConditions(Array conditions)
@@ -88,7 +87,7 @@ Retrieves rule condition set by constructor or `setCondition()`
### setEvent(Object event)
-Helper for setting rule event. Alternative to passing the `event` option to the rule constructor.
+Helper for setting rule event. Alternative to passing the `event` option to the rule constructor.
### getEvent() -> Object
@@ -104,15 +103,15 @@ Retrieves rule priority set by constructor or `setPriority()`
### toJSON(Boolean stringify = true)
-Serializes the rule into a JSON string. Often used when persisting rules.
+Serializes the rule into a JSON string. Often used when persisting rules.
```js
-let jsonString = rule.toJSON() // string: '{"conditions":{"all":[]},"priority":50 ...
+let jsonString = rule.toJSON(); // string: '{"conditions":{"all":[]},"priority":50 ...
-let rule = new Rule(jsonString) // restored rule; same conditions, priority, event
+let rule = new Rule(jsonString); // restored rule; same conditions, priority, event
// without stringifying
-let jsonObject = rule.toJSON(false) // object: {conditions:{ all: [] }, priority: 50 ...
+let jsonObject = rule.toJSON(false); // object: {conditions:{ all: [] }, priority: 50 ...
```
## Conditions
@@ -121,7 +120,7 @@ Rule conditions are a combination of facts, operators, and values that determine
### Basic conditions
-The simplest form of a condition consists of a `fact`, an `operator`, and a `value`. When the engine runs, the operator is used to compare the fact against the value.
+The simplest form of a condition consists of a `fact`, an `operator`, and a `value`. When the engine runs, the operator is used to compare the fact against the value.
```js
// my-fact <= 1
@@ -129,58 +128,74 @@ let rule = new Rule({
conditions: {
all: [
{
- fact: 'my-fact',
- operator: 'lessThanInclusive',
- value: 1
- }
- ]
- }
-})
+ fact: "my-fact",
+ operator: "lessThanInclusive",
+ value: 1,
+ },
+ ],
+ },
+});
```
See the [hello-world](../examples/01-hello-world.js) example.
### Boolean expressions: `all`, `any`, and `not`
-Each rule's conditions *must* have an `all` or `any` operator containing an array of conditions at its root, a `not` operator containing a single condition, or a condition reference. The `all` operator specifies that all conditions contained within must be truthy for the rule to be considered a `success`. The `any` operator only requires one condition to be truthy for the rule to succeed. The `not` operator will negate whatever condition it contains.
+Each rule's conditions _must_ have an `all` or `any` operator containing an array of conditions at its root, a `not` operator containing a single condition, or a condition reference. The `all` operator specifies that all conditions contained within must be truthy for the rule to be considered a `success`. The `any` operator only requires one condition to be truthy for the rule to succeed. The `not` operator will negate whatever condition it contains.
```js
// all:
let rule = new Rule({
conditions: {
all: [
- { /* condition 1 */ },
- { /* condition 2 */ },
- { /* condition n */ },
- ]
- }
-})
+ {
+ /* condition 1 */
+ },
+ {
+ /* condition 2 */
+ },
+ {
+ /* condition n */
+ },
+ ],
+ },
+});
// any:
let rule = new Rule({
conditions: {
any: [
- { /* condition 1 */ },
- { /* condition 2 */ },
- { /* condition n */ },
+ {
+ /* condition 1 */
+ },
+ {
+ /* condition 2 */
+ },
+ {
+ /* condition n */
+ },
{
not: {
- all: [ /* more conditions */ ]
- }
- }
- ]
- }
-})
+ all: [
+ /* more conditions */
+ ],
+ },
+ },
+ ],
+ },
+});
// not:
let rule = new Rule({
conditions: {
- not: { /* condition */ }
- }
-})
+ not: {
+ /* condition */
+ },
+ },
+});
```
-Notice in the second example how `all`, `any`, and `not` can be nested within one another to produce complex boolean expressions. See the [nested-boolean-logic](../examples/02-nested-boolean-logic.js) example.
+Notice in the second example how `all`, `any`, and `not` can be nested within one another to produce complex boolean expressions. See the [nested-boolean-logic](../examples/02-nested-boolean-logic.js) example.
### Condition Reference
@@ -190,47 +205,51 @@ Rules may reference conditions based on their name.
let rule = new Rule({
conditions: {
all: [
- { condition: 'conditionName' },
- { /* additional condition */ }
- ]
- }
-})
+ { condition: "conditionName" },
+ {
+ /* additional condition */
+ },
+ ],
+ },
+});
```
Before running the rule the condition should be added to the engine.
```js
-engine.setCondition('conditionName', { /* conditions */ });
+engine.setCondition("conditionName", {
+ /* conditions */
+});
```
Conditions must start with `all`, `any`, `not`, or reference a condition.
### Condition helpers: `params`
-Sometimes facts require additional input to perform calculations. For this, the `params` property is passed as an argument to the fact handler. `params` essentially functions as fact arguments, enabling fact handlers to be more generic and reusable.
+Sometimes facts require additional input to perform calculations. For this, the `params` property is passed as an argument to the fact handler. `params` essentially functions as fact arguments, enabling fact handlers to be more generic and reusable.
```js
// product-price retrieves any product's price based on the "productId" in "params"
-engine.addFact('product-price', function (params, almanac) {
+engine.addFact("product-price", function (params, almanac) {
return productLoader(params.productId) // loads the "widget" product
- .then(product => product.price)
-})
+ .then((product) => product.price);
+});
// identifies whether the current widget price is above $100
let rule = new Rule({
conditions: {
all: [
{
- fact: 'product-price',
+ fact: "product-price",
params: {
- productId: 'widget' // specifies which product to load
+ productId: "widget", // specifies which product to load
},
- operator: 'greaterThan',
- value: 100
- }
- ]
- }
-})
+ operator: "greaterThan",
+ value: 100,
+ },
+ ],
+ },
+});
```
See the [dynamic-facts](../examples/03-dynamic-facts) example
@@ -242,29 +261,28 @@ In the `params` example above, the dynamic fact handler loads an object, then re
To address this, a `path` property may be provided to traverse fact data using [json-path](https://goessner.net/articles/JsonPath/) syntax. The example above becomes simpler, and only one fact handler must be written:
```js
-
// product-price retrieves any product's price based on the "productId" in "params"
-engine.addFact('product-price', function (params, almanac) {
+engine.addFact("product-price", function (params, almanac) {
// NOTE: `then` is not required; .price is specified via "path" below
- return productLoader(params.productId)
-})
+ return productLoader(params.productId);
+});
// identifies whether the current widget price is above $100
let rule = new Rule({
conditions: {
all: [
{
- fact: 'product-price',
- path: '$.price',
+ fact: "product-price",
+ path: "$.price",
params: {
- productId: 'widget'
+ productId: "widget",
},
- operator: 'greaterThan',
- value: 100
- }
- ]
- }
-})
+ operator: "greaterThan",
+ value: 100,
+ },
+ ],
+ },
+});
```
json-path support is provided by [jsonpath-plus](https://github.com/s3u/JSONPath)
@@ -303,7 +321,7 @@ This feature may be useful in cases where the higher performance offered by simp
### Comparing facts
-Sometimes it is necessary to compare facts against other facts. This can be accomplished by nesting the second fact within the `value` property. This second fact has access to the same `params` and `path` helpers as the primary fact.
+Sometimes it is necessary to compare facts against other facts. This can be accomplished by nesting the second fact within the `value` property. This second fact has access to the same `params` and `path` helpers as the primary fact.
```js
// identifies whether the current widget price is above a maximum
@@ -312,46 +330,47 @@ let rule = new Rule({
all: [
// widget-price > budget
{
- fact: 'product-price',
+ fact: "product-price",
params: {
- productId: 'widget',
- path: '$.price'
+ productId: "widget",
+ path: "$.price",
},
- operator: 'greaterThan',
+ operator: "greaterThan",
// "value" contains a fact
value: {
- fact: 'budget' // "params" and "path" helpers are available as well
- }
- }
- ]
- }
-})
+ fact: "budget", // "params" and "path" helpers are available as well
+ },
+ },
+ ],
+ },
+});
```
+
See the [fact-comparison](../examples/08-fact-comparison.js) example
## Events
Listen for `success` and `failure` events emitted when rule is evaluated.
-#### ```rule.on('success', Function(Object event, Almanac almanac, RuleResult ruleResult))```
+#### `rule.on('success', Function(Object event, Almanac almanac, RuleResult ruleResult))`
The callback will receive the event object, the current [Almanac](./almanac.md), and the [Rule Result](./rules.md#rule-results).
```js
// whenever rule is evaluated and the conditions pass, 'success' will trigger
-rule.on('success', function(event, almanac, ruleResult) {
- console.log(event) // { type: 'my-event', params: { id: 1 }
-})
+rule.on("success", function (event, almanac, ruleResult) {
+ console.log(event); // { type: 'my-event', params: { id: 1 }
+});
```
-#### ```rule.on('failure', Function(Object event, Almanac almanac, RuleResult ruleResult))```
+#### `rule.on('failure', Function(Object event, Almanac almanac, RuleResult ruleResult))`
-Companion to `success`, except fires when the rule fails. The callback will receive the event object, the current [Almanac](./almanac.md), and the [Rule Result](./rules.md#rule-results).
+Companion to `success`, except fires when the rule fails. The callback will receive the event object, the current [Almanac](./almanac.md), and the [Rule Result](./rules.md#rule-results).
```js
-engine.on('failure', function(event, almanac, ruleResult) {
- console.log(event) // { type: 'my-event', params: { id: 1 }
-})
+engine.on("failure", function (event, almanac, ruleResult) {
+ console.log(event); // { type: 'my-event', params: { id: 1 }
+});
```
### Referencing Facts In Events
@@ -361,90 +380,94 @@ With the engine option [`replaceFactsInEventParams`](./engine.md#options) the pa
```js
const engine = new Engine([], { replaceFactsInEventParams: true });
engine.addRule({
- conditions: { /* ... */ },
- event: {
- type: "gameover",
- params: {
- initials: {
- fact: "currentHighScore",
- path: "$.initials",
- params: { foo: 'bar' }
- }
- }
- }
- })
+ conditions: {
+ /* ... */
+ },
+ event: {
+ type: "gameover",
+ params: {
+ initials: {
+ fact: "currentHighScore",
+ path: "$.initials",
+ params: { foo: "bar" },
+ },
+ },
+ },
+});
```
See [11-using-facts-in-events.js](../examples/11-using-facts-in-events.js) for a complete example.
## Operators
-Each rule condition must begin with a boolean operator(```all```, ```any```, or ```not```) at its root.
+Each rule condition must begin with a boolean operator(`all`, `any`, or `not`) at its root.
-The ```operator``` compares the value returned by the ```fact``` to what is stored in the ```value``` property. If the result is truthy, the condition passes.
+The `operator` compares the value returned by the `fact` to what is stored in the `value` property. If the result is truthy, the condition passes.
### String and Numeric operators:
- ```equal``` - _fact_ must equal _value_
+`equal` - _fact_ must equal _value_
- ```notEqual``` - _fact_ must not equal _value_
+`notEqual` - _fact_ must not equal _value_
- _these operators use strict equality (===) and inequality (!==)_
+_these operators use strict equality (===) and inequality (!==)_
### Numeric operators:
- ```lessThan``` - _fact_ must be less than _value_
+`lessThan` - _fact_ must be less than _value_
- ```lessThanInclusive```- _fact_ must be less than or equal to _value_
+`lessThanInclusive`- _fact_ must be less than or equal to _value_
- ```greaterThan``` - _fact_ must be greater than _value_
+`greaterThan` - _fact_ must be greater than _value_
- ```greaterThanInclusive```- _fact_ must be greater than or equal to _value_
+`greaterThanInclusive`- _fact_ must be greater than or equal to _value_
### Array operators:
- ```in``` - _fact_ must be included in _value_ (an array)
+`in` - _fact_ must be included in _value_ (an array)
- ```notIn``` - _fact_ must not be included in _value_ (an array)
+`notIn` - _fact_ must not be included in _value_ (an array)
- ```contains``` - _fact_ (an array) must include _value_
+`contains` - _fact_ (an array) must include _value_
- ```doesNotContain``` - _fact_ (an array) must not include _value_
+`doesNotContain` - _fact_ (an array) must not include _value_
## Operator Decorators
-Operator Decorators modify the behavior of an operator either by changing the input or the output. To specify one or more decorators prefix the name of the operator with them in the ```operator``` field and use the colon (```:```) symbol to separate decorators and the operator. For instance ```everyFact:greaterThan``` will produce an operator that checks that every element of the _fact_ is greater than the value.
+Operator Decorators modify the behavior of an operator either by changing the input or the output. To specify one or more decorators prefix the name of the operator with them in the `operator` field and use the colon (`:`) symbol to separate decorators and the operator. For instance `everyFact:greaterThan` will produce an operator that checks that every element of the _fact_ is greater than the value.
See [12-using-operator-decorators.js](../examples/13-using-operator-decorators.js) for an example.
### Array Decorators:
- ```everyFact``` - _fact_ (an array) must have every element pass the decorated operator for _value_
+`everyFact` - _fact_ (an array) must have every element pass the decorated operator for _value_
- ```everyValue``` - _fact_ must pass the decorated operator for every element of _value_ (an array)
+`everyValue` - _fact_ must pass the decorated operator for every element of _value_ (an array)
- ```someFact``` - _fact_ (an array) must have at-least one element pass the decorated operator for _value_
+`someFact` - _fact_ (an array) must have at-least one element pass the decorated operator for _value_
- ```someValue``` - _fact_ must pass the decorated operator for at-least one element of _value_ (an array)
+`someValue` - _fact_ must pass the decorated operator for at-least one element of _value_ (an array)
### Logical Decorators
- ```not``` - negate the result of the decorated operator
+`not` - negate the result of the decorated operator
### Utility Decorators
- ```swap``` - Swap _fact_ and _value_ for the decorated operator
+
+`swap` - Swap _fact_ and _value_ for the decorated operator
### Decorator Composition
-Operator Decorators can be composed by chaining them together with the colon to separate them. For example if you wanted to ensure that every number in an array was less than every number in another array you could use ```everyFact:everyValue:lessThan```.
+Operator Decorators can be composed by chaining them together with the colon to separate them. For example if you wanted to ensure that every number in an array was less than every number in another array you could use `everyFact:everyValue:lessThan`.
-```swap``` and ```not``` are useful when there are not symmetric or negated versions of custom operators, for instance you could check if a _value_ does not start with a letter contained in a _fact_ using the decorated custom operator ```swap:not:startsWithLetter```. This allows a single custom operator to have 4 permutations.
+`swap` and `not` are useful when there are not symmetric or negated versions of custom operators, for instance you could check if a _value_ does not start with a letter contained in a _fact_ using the decorated custom operator `swap:not:startsWithLetter`. This allows a single custom operator to have 4 permutations.
## Rule Results
-After a rule is evaluated, a `rule result` object is provided to the `success` and `failure` events. This argument is similar to a regular rule, and contains additional metadata about how the rule was evaluated. Rule results can be used to extract the results of individual conditions, computed fact values, and boolean logic results. `name` can be used to easily identify a given rule.
+After a rule is evaluated, a `rule result` object is provided to the `success` and `failure` events. This argument is similar to a regular rule, and contains additional metadata about how the rule was evaluated. Rule results can be used to extract the results of individual conditions, computed fact values, and boolean logic results. `name` can be used to easily identify a given rule.
Rule results are structured similar to rules, with two additional pieces of metadata sprinkled throughout: `result` and `factResult`
+
```js
{
result: false, // denotes whether rule computed truthy or falsey
@@ -474,14 +497,14 @@ A demonstration can be found in the [rule-results](../examples/09-rule-results.j
## Persisting
-Rules may be easily converted to JSON and persisted to a database, file system, or elsewhere. To convert a rule to JSON, simply call the ```rule.toJSON()``` method. Later, a rule may be restored by feeding the json into the Rule constructor.
+Rules may be easily converted to JSON and persisted to a database, file system, or elsewhere. To convert a rule to JSON, simply call the `rule.toJSON()` method. Later, a rule may be restored by feeding the json into the Rule constructor.
```js
// save somewhere...
-let jsonString = rule.toJSON()
+let jsonString = rule.toJSON();
// ...later:
-let rule = new Rule(jsonString)
+let rule = new Rule(jsonString);
```
-_Why aren't "fact" methods persistable?_ This is by design, for several reasons. Firstly, facts are by definition business logic bespoke to your application, and therefore lie outside the scope of this library. Secondly, many times this request indicates a design smell; try thinking of other ways to compose the rules and facts to accomplish the same objective. Finally, persisting fact methods would involve serializing javascript code, and restoring it later via ``eval()``.
+_Why aren't "fact" methods persistable?_ This is by design, for several reasons. Firstly, facts are by definition business logic bespoke to your application, and therefore lie outside the scope of this library. Secondly, many times this request indicates a design smell; try thinking of other ways to compose the rules and facts to accomplish the same objective. Finally, persisting fact methods would involve serializing javascript code, and restoring it later via `eval()`.
diff --git a/docs/walkthrough.md b/docs/walkthrough.md
index 21650dc4..8a48bb3b 100644
--- a/docs/walkthrough.md
+++ b/docs/walkthrough.md
@@ -1,63 +1,65 @@
# Walkthrough
-* [Step 1: Create an Engine](#step-1-create-an-engine)
-* [Step 2: Add Rules](#step-2-add-rules)
-* [Step 3: Define Facts](#step-3-define-facts)
-* [Step 4: Handing Events](#step-4-handing-events)
-* [Step 5: Run the engine](#step-5-run-the-engine)
+- [Step 1: Create an Engine](#step-1-create-an-engine)
+- [Step 2: Add Rules](#step-2-add-rules)
+- [Step 3: Define Facts](#step-3-define-facts)
+- [Step 4: Handing Events](#step-4-handing-events)
+- [Step 5: Run the engine](#step-5-run-the-engine)
## Step 1: Create an Engine
```js
- let { Engine } = require('json-rules-engine');
- let engine = new Engine();
+let { Engine } = require("json-rules-engine");
+let engine = new Engine();
```
More on engines can be found [here](./engine.md)
## Step 2: Add Rules
-Rules are composed of two components: conditions and events. _Conditions_ are a set of requirements that must be met to trigger the rule's _event_.
+Rules are composed of two components: conditions and events. _Conditions_ are a set of requirements that must be met to trigger the rule's _event_.
```js
let event = {
- type: 'young-adult-rocky-mnts',
+ type: "young-adult-rocky-mnts",
params: {
- giftCard: 'amazon',
- value: 50
- }
+ giftCard: "amazon",
+ value: 50,
+ },
};
let conditions = {
all: [
{
- fact: 'age',
- operator: 'greaterThanInclusive',
- value: 18
- }, {
- fact: 'age',
- operator: 'lessThanInclusive',
- value: 25
+ fact: "age",
+ operator: "greaterThanInclusive",
+ value: 18,
+ },
+ {
+ fact: "age",
+ operator: "lessThanInclusive",
+ value: 25,
},
{
any: [
{
- fact: 'state',
+ fact: "state",
params: {
- country: 'us'
+ country: "us",
},
- operator: 'equal',
- value: 'CO'
- }, {
- fact: 'state',
+ operator: "equal",
+ value: "CO",
+ },
+ {
+ fact: "state",
params: {
- country: 'us'
+ country: "us",
},
- operator: 'equal',
- value: 'UT'
- }
- ]
- }
- ]
+ operator: "equal",
+ value: "UT",
+ },
+ ],
+ },
+ ],
};
engine.addRule({ conditions, event });
```
@@ -68,7 +70,7 @@ More on rules can be found [here](./rules.md)
### Step 3: Define Facts
-Facts are constant values or pure functions. Using the current example, if the engine were to be run, it would throw an exception: `Undefined fact:'age'` (note: this behavior can be disable via [engine options](./engine.md#Options)).
+Facts are constant values or pure functions. Using the current example, if the engine were to be run, it would throw an exception: `Undefined fact:'age'` (note: this behavior can be disable via [engine options](./engine.md#Options)).
Let's define some facts:
@@ -76,50 +78,54 @@ Let's define some facts:
/*
* Define the 'state' fact
*/
-let stateFact = function(params, almanac) {
+let stateFact = function (params, almanac) {
// rule "params" value is passed to the fact
// 'almanac' can be used to lookup other facts
// via almanac.factValue()
- return almanac.factValue('zip-code')
- .then(zip => {
- return stateLookupByZip(params.country, zip);
- });
+ return almanac.factValue("zip-code").then((zip) => {
+ return stateLookupByZip(params.country, zip);
+ });
};
-engine.addFact('state', stateFact);
+engine.addFact("state", stateFact);
/*
* Define the 'age' fact
*/
-let ageFact = function(params, almanac) {
+let ageFact = function (params, almanac) {
// facts may return a promise when performing asynchronous operations
// such as database calls, http requests, etc to gather data
- return almanac.factValue('userId').then((userId) => {
- return getUser(userId);
- }).then((user) => {
- return user.age;
- })
+ return almanac
+ .factValue("userId")
+ .then((userId) => {
+ return getUser(userId);
+ })
+ .then((user) => {
+ return user.age;
+ });
};
-engine.addFact('age', ageFact);
+engine.addFact("age", ageFact);
/*
* Define the 'zip-code' fact
*/
-let zipCodeFact = function(params, almanac) {
- return almanac.factValue('userId').then((userId) => {
- return getUser(userId);
- }).then((user) => {
- return user.zipCode;
- })
+let zipCodeFact = function (params, almanac) {
+ return almanac
+ .factValue("userId")
+ .then((userId) => {
+ return getUser(userId);
+ })
+ .then((user) => {
+ return user.zipCode;
+ });
};
-engine.addFact('zip-code', zipCodeFact);
+engine.addFact("zip-code", zipCodeFact);
```
-Now when the engine is run, it will call the methods above whenever it encounters the ```fact: "age"``` or ```fact: "state"``` properties.
+Now when the engine is run, it will call the methods above whenever it encounters the `fact: "age"` or `fact: "state"` properties.
-**Important:** facts should be *pure functions*; their computed values will vary based on the ```params``` argument. By establishing facts as pure functions, it allows the rules engine to cache results throughout each ```run()```; facts called multiple times with the same ```params``` will trigger the computation once and cache the results for future calls. If fact caching not desired, this behavior can be turned off via the options; see the [docs](./facts.md).
-
-More on facts can be found [here](./facts.md). More on almanacs can be found [here](./almanac.md)
+**Important:** facts should be _pure functions_; their computed values will vary based on the `params` argument. By establishing facts as pure functions, it allows the rules engine to cache results throughout each `run()`; facts called multiple times with the same `params` will trigger the computation once and cache the results for future calls. If fact caching not desired, this behavior can be turned off via the options; see the [docs](./facts.md).
+More on facts can be found [here](./facts.md). More on almanacs can be found [here](./almanac.md)
## Step 4: Handing Events
@@ -127,7 +133,7 @@ When rule conditions are met, the application needs to respond to the event that
```js
// subscribe directly to the 'young-adult' event
-engine.on('young-adult-rocky-mnts', (params) => {
+engine.on("young-adult-rocky-mnts", (params) => {
// params: {
// giftCard: 'amazon',
// value: 50
@@ -137,8 +143,8 @@ engine.on('young-adult-rocky-mnts', (params) => {
// - OR -
// subscribe to any event emitted by the engine
-engine.on('success', function (event, almanac, ruleResult) {
- console.log('Success event:\n', event);
+engine.on("success", function (event, almanac, ruleResult) {
+ console.log("Success event:\n", event);
// event: {
// type: "young-adult-rocky-mnts",
// params: {
@@ -151,46 +157,51 @@ engine.on('success', function (event, almanac, ruleResult) {
## Step 5: Run the engine
-Running an engine executes the rules, and fires off event events for conditions that were met. The fact results cache will be cleared with each ```run()```
+Running an engine executes the rules, and fires off event events for conditions that were met. The fact results cache will be cleared with each `run()`
```js
// evaluate the rules
//engine.run();
// Optionally, facts known at runtime may be passed to run()
-engine.run({ userId: 1 }); // any time a rule condition requires 'userId', '1' will be returned
+engine.run({ userId: 1 }); // any time a rule condition requires 'userId', '1' will be returned
// run() returns a promise
engine.run({ userId: 4 }).then(({ events }) => {
- console.log('all rules executed; the following events were triggered: ', events.map(result => JSON.stringify(event)))
+ console.log(
+ "all rules executed; the following events were triggered: ",
+ events.map((result) => JSON.stringify(event)),
+ );
});
```
+
Helper methods (for this example)
+
```js
function stateLookupByZip(country, zip) {
var state;
switch (zip.toString()) {
- case '80014':
- state = 'CO';
+ case "80014":
+ state = "CO";
break;
- case '84101':
- state = 'UT';
+ case "84101":
+ state = "UT";
break;
- case '90210':
- state = 'CA';
+ case "90210":
+ state = "CA";
break;
default:
- state = 'NY';
+ state = "NY";
}
return state;
}
var users = {
- 1: {age: 22, zipCode: 80014},
- 2: {age: 16, zipCode: 80014},
- 3: {age: 35, zipCode: 84101},
- 4: {age: 23, zipCode: 90210},
+ 1: { age: 22, zipCode: 80014 },
+ 2: { age: 16, zipCode: 80014 },
+ 3: { age: 35, zipCode: 84101 },
+ 4: { age: 23, zipCode: 90210 },
};
function getUser(id) {
diff --git a/eslint.config.mjs b/eslint.config.mjs
new file mode 100644
index 00000000..1acd9003
--- /dev/null
+++ b/eslint.config.mjs
@@ -0,0 +1,23 @@
+import globals from "globals";
+import pluginJs from "@eslint/js";
+import tseslint from "typescript-eslint";
+
+export default [
+ {
+ files: ["**/*.{js,mjs,cjs,ts,mts}"],
+ },
+ {
+ ignores: ["dist/"],
+ },
+ { languageOptions: { globals: { ...globals.browser, ...globals.node } } },
+ pluginJs.configs.recommended,
+ ...tseslint.configs.recommended,
+ {
+ rules: {
+ "@typescript-eslint/no-unused-vars": [
+ "error",
+ { argsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" },
+ ],
+ },
+ },
+];
diff --git a/examples/01-hello-world.js b/examples/01-hello-world.mts
similarity index 62%
rename from examples/01-hello-world.js
rename to examples/01-hello-world.mts
index 6a154ff2..f0cc1f58 100644
--- a/examples/01-hello-world.js
+++ b/examples/01-hello-world.mts
@@ -1,4 +1,3 @@
-'use strict'
/*
* This is the hello-world example from the README.
*
@@ -9,14 +8,14 @@
* DEBUG=json-rules-engine node ./examples/01-hello-world.js
*/
-require('colors')
-const { Engine } = require('json-rules-engine')
+import "colors";
+import { Engine } from "json-rules-engine";
-async function start () {
+async function start() {
/**
* Setup a new engine
*/
- const engine = new Engine()
+ const engine = new Engine();
/**
* Create a rule
@@ -24,35 +23,37 @@ async function start () {
engine.addRule({
// define the 'conditions' for when "hello world" should display
conditions: {
- all: [{
- fact: 'displayMessage',
- operator: 'equal',
- value: true
- }]
+ all: [
+ {
+ fact: "displayMessage",
+ operator: "equal",
+ value: true,
+ },
+ ],
},
// define the 'event' that will fire when the condition evaluates truthy
event: {
- type: 'message',
+ type: "message",
params: {
- data: 'hello-world!'
- }
- }
- })
+ data: "hello-world!",
+ },
+ },
+ });
/**
* Define a 'displayMessage' as a constant value
* Fact values do NOT need to be known at engine runtime; see the
* 03-dynamic-facts.js example for how to pull in data asynchronously during runtime
*/
- const facts = { displayMessage: true }
+ const facts = { displayMessage: true };
// engine.run() evaluates the rule using the facts provided
- const { events } = await engine.run(facts)
+ const { events } = await engine.run(facts);
- events.map(event => console.log(event.params.data.green))
+ events.map((event) => console.log(event.params!.data.green));
}
-start()
+start();
/*
* OUTPUT:
*
diff --git a/examples/02-nested-boolean-logic.js b/examples/02-nested-boolean-logic.js
deleted file mode 100644
index 8fcc0446..00000000
--- a/examples/02-nested-boolean-logic.js
+++ /dev/null
@@ -1,77 +0,0 @@
-'use strict'
-/*
- * This example demonstates nested boolean logic - e.g. (x OR y) AND (a OR b).
- *
- * Usage:
- * node ./examples/02-nested-boolean-logic.js
- *
- * For detailed output:
- * DEBUG=json-rules-engine node ./examples/02-nested-boolean-logic.js
- */
-
-require('colors')
-const { Engine } = require('json-rules-engine')
-
-async function start () {
- /**
- * Setup a new engine
- */
- const engine = new Engine()
-
- // define a rule for detecting the player has exceeded foul limits. Foul out any player who:
- // (has committed 5 fouls AND game is 40 minutes) OR (has committed 6 fouls AND game is 48 minutes)
- engine.addRule({
- conditions: {
- any: [{
- all: [{
- fact: 'gameDuration',
- operator: 'equal',
- value: 40
- }, {
- fact: 'personalFoulCount',
- operator: 'greaterThanInclusive',
- value: 5
- }],
- name: 'short foul limit'
- }, {
- all: [{
- fact: 'gameDuration',
- operator: 'equal',
- value: 48
- }, {
- not: {
- fact: 'personalFoulCount',
- operator: 'lessThan',
- value: 6
- }
- }],
- name: 'long foul limit'
- }]
- },
- event: { // define the event to fire when the conditions evaluate truthy
- type: 'fouledOut',
- params: {
- message: 'Player has fouled out!'
- }
- }
- })
-
- /**
- * define the facts
- * note: facts may be loaded asynchronously at runtime; see the advanced example below
- */
- const facts = {
- personalFoulCount: 6,
- gameDuration: 40
- }
-
- const { events } = await engine.run(facts)
-
- events.map(event => console.log(event.params.message.red))
-}
-start()
-/*
- * OUTPUT:
- *
- * Player has fouled out!
- */
diff --git a/examples/02-nested-boolean-logic.mts b/examples/02-nested-boolean-logic.mts
new file mode 100644
index 00000000..c596e9c2
--- /dev/null
+++ b/examples/02-nested-boolean-logic.mts
@@ -0,0 +1,86 @@
+/*
+ * This example demonstates nested boolean logic - e.g. (x OR y) AND (a OR b).
+ *
+ * Usage:
+ * node ./examples/02-nested-boolean-logic.js
+ *
+ * For detailed output:
+ * DEBUG=json-rules-engine node ./examples/02-nested-boolean-logic.js
+ */
+
+import "colors";
+import { Engine } from "json-rules-engine";
+
+async function start() {
+ /**
+ * Setup a new engine
+ */
+ const engine = new Engine();
+
+ // define a rule for detecting the player has exceeded foul limits. Foul out any player who:
+ // (has committed 5 fouls AND game is 40 minutes) OR (has committed 6 fouls AND game is 48 minutes)
+ engine.addRule({
+ conditions: {
+ any: [
+ {
+ all: [
+ {
+ fact: "gameDuration",
+ operator: "equal",
+ value: 40,
+ },
+ {
+ fact: "personalFoulCount",
+ operator: "greaterThanInclusive",
+ value: 5,
+ },
+ ],
+ name: "short foul limit",
+ },
+ {
+ all: [
+ {
+ fact: "gameDuration",
+ operator: "equal",
+ value: 48,
+ },
+ {
+ not: {
+ fact: "personalFoulCount",
+ operator: "lessThan",
+ value: 6,
+ },
+ },
+ ],
+ name: "long foul limit",
+ },
+ ],
+ },
+ event: {
+ // define the event to fire when the conditions evaluate truthy
+ type: "fouledOut",
+ params: {
+ message: "Player has fouled out!",
+ },
+ },
+ });
+
+ /**
+ * define the facts
+ * note: facts may be loaded asynchronously at runtime; see the advanced example below
+ */
+ const facts = {
+ personalFoulCount: 6,
+ gameDuration: 40,
+ };
+
+ const { events } = await engine.run(facts);
+
+ events.map((event) => console.log(event.params!.message.red));
+}
+start();
+/*
+ * OUTPUT:
+ *
+ * Player has fouled out!
+ */
diff --git a/examples/03-dynamic-facts.js b/examples/03-dynamic-facts.mts
similarity index 51%
rename from examples/03-dynamic-facts.js
rename to examples/03-dynamic-facts.mts
index e7ddc502..e241be02 100644
--- a/examples/03-dynamic-facts.js
+++ b/examples/03-dynamic-facts.mts
@@ -1,4 +1,3 @@
-'use strict'
/*
* This example demonstrates computing fact values at runtime, and leveraging the 'path' feature
* to select object properties returned by facts
@@ -10,17 +9,17 @@
* DEBUG=json-rules-engine node ./examples/03-dynamic-facts.js
*/
-require('colors')
-const { Engine } = require('json-rules-engine')
+import "colors";
+import { Engine } from "json-rules-engine";
// example client for making asynchronous requests to an api, database, etc
-const apiClient = require('./support/account-api-client')
+import apiClient from "./support/account-api-client.mjs";
-async function start () {
+async function start() {
/**
* Setup a new engine
*/
- const engine = new Engine()
+ const engine = new Engine();
/**
* Rule for identifying microsoft employees taking pto on christmas
@@ -30,51 +29,55 @@ async function start () {
*/
const microsoftRule = {
conditions: {
- all: [{
- fact: 'account-information',
- operator: 'equal',
- value: 'microsoft',
- path: '$.company' // access the 'company' property of "account-information"
- }, {
- fact: 'account-information',
- operator: 'in',
- value: ['active', 'paid-leave'], // 'status'' can be active or paid-leave
- path: '$.status' // access the 'status' property of "account-information"
- }, {
- fact: 'account-information',
- operator: 'contains',
- value: '2016-12-25',
- path: '$.ptoDaysTaken' // access the 'ptoDaysTaken' property of "account-information"
- }]
+ all: [
+ {
+ fact: "account-information",
+ operator: "equal",
+ value: "microsoft",
+ path: "$.company", // access the 'company' property of "account-information"
+ },
+ {
+ fact: "account-information",
+ operator: "in",
+ value: ["active", "paid-leave"], // 'status'' can be active or paid-leave
+ path: "$.status", // access the 'status' property of "account-information"
+ },
+ {
+ fact: "account-information",
+ operator: "contains",
+ value: "2016-12-25",
+ path: "$.ptoDaysTaken", // access the 'ptoDaysTaken' property of "account-information"
+ },
+ ],
},
event: {
- type: 'microsoft-christmas-pto',
+ type: "microsoft-christmas-pto",
params: {
- message: 'current microsoft employee taking christmas day off'
- }
- }
- }
- engine.addRule(microsoftRule)
+ message: "current microsoft employee taking christmas day off",
+ },
+ },
+ };
+ engine.addRule(microsoftRule);
/**
* 'account-information' fact executes an api call and retrieves account data, feeding the results
* into the engine. The major advantage of this technique is that although there are THREE conditions
* requiring this data, only ONE api call is made. This results in much more efficient runtime performance.
*/
- engine.addFact('account-information', function (params, almanac) {
- return almanac.factValue('accountId')
- .then(accountId => {
- return apiClient.getAccountInformation(accountId)
- })
- })
+ engine.addFact("account-information", async function (_params, almanac) {
+ const accountId = await almanac.factValue("accountId");
+ return apiClient.getAccountInformation(accountId);
+ });
// define fact(s) known at runtime
- const facts = { accountId: 'lincoln' }
- const { events } = await engine.run(facts)
+ const facts = { accountId: "lincoln" };
+ const { events } = await engine.run(facts);
- console.log(facts.accountId + ' is a ' + events.map(event => event.params.message))
+ console.log(
+ facts.accountId + " is a " + events.map((event) => event.params!.message),
+ );
}
-start()
+start();
/*
* OUTPUT:
diff --git a/examples/04-fact-dependency.js b/examples/04-fact-dependency.js
deleted file mode 100644
index bc332a9b..00000000
--- a/examples/04-fact-dependency.js
+++ /dev/null
@@ -1,140 +0,0 @@
-'use strict'
-/*
- * This is an advanced example that demonstrates facts with dependencies
- * on other facts. In addition, it demonstrates facts that load data asynchronously
- * from outside sources (api's, databases, etc)
- *
- * Usage:
- * node ./examples/04-fact-dependency.js
- *
- * For detailed output:
- * DEBUG=json-rules-engine node ./examples/04-fact-dependency.js
- */
-
-require('colors')
-const { Engine } = require('json-rules-engine')
-const accountClient = require('./support/account-api-client')
-
-async function start () {
- /**
- * Setup a new engine
- */
- const engine = new Engine()
-
- /**
- * Rule for identifying microsoft employees that have been terminated.
- * - Demonstrates re-using a same fact with different parameters
- * - Demonstrates calling a base fact, which serves to load data once and reuse later
- */
- const microsoftRule = {
- conditions: {
- all: [{
- fact: 'account-information',
- operator: 'equal',
- value: 'microsoft',
- path: '$.company'
- }, {
- fact: 'account-information',
- operator: 'equal',
- value: 'terminated',
- path: '$.status'
- }]
- },
- event: { type: 'microsoft-terminated-employees' }
- }
- engine.addRule(microsoftRule)
-
- /**
- * Rule for identifying accounts older than 5 years
- * - Demonstrates calling a base fact, also shared by the account-information-field fact
- * - Demonstrates performing computations on data retrieved by base fact
- */
- const tenureRule = {
- conditions: {
- all: [{
- fact: 'employee-tenure',
- operator: 'greaterThanInclusive',
- value: 5,
- params: {
- unit: 'years'
- }
- }]
- },
- event: { type: 'five-year-tenure' }
- }
- engine.addRule(tenureRule)
-
- /**
- * Register listeners with the engine for rule success and failure
- */
- let facts
- engine
- .on('success', event => {
- console.log(facts.accountId + ' DID '.green + 'meet conditions for the ' + event.type.underline + ' rule.')
- })
- .on('failure', event => {
- console.log(facts.accountId + ' did ' + 'NOT'.red + ' meet conditions for the ' + event.type.underline + ' rule.')
- })
-
- /**
- * 'account-information' fact executes an api call and retrieves account data
- * - Demonstrates facts called only by other facts and never mentioned directly in a rule
- */
- engine.addFact('account-information', (params, almanac) => {
- return almanac.factValue('accountId')
- .then(accountId => {
- return accountClient.getAccountInformation(accountId)
- })
- })
-
- /**
- * 'employee-tenure' fact retrieves account-information, and computes the duration of employment
- * since the account was created using 'accountInformation.createdAt'
- */
- engine.addFact('employee-tenure', (params, almanac) => {
- return almanac.factValue('account-information')
- .then(accountInformation => {
- const created = new Date(accountInformation.createdAt)
- const now = new Date()
- switch (params.unit) {
- case 'years':
- return now.getFullYear() - created.getFullYear()
- case 'milliseconds':
- default:
- return now.getTime() - created.getTime()
- }
- })
- .catch(console.log)
- })
-
- // first run, using washington's facts
- console.log('-- FIRST RUN --')
- facts = { accountId: 'washington' }
- await engine.run(facts)
-
- console.log('-- SECOND RUN --')
- // second run, using jefferson's facts; facts & evaluation are independent of the first run
- facts = { accountId: 'jefferson' }
- await engine.run(facts)
-
- /*
- * NOTES:
- *
- * - Notice that although a total of 6 conditions were evaluated using
- * account-information (3 rule conditions x 2 accounts), the account-information api call
- * is only called twice -- once for each account. This is due to the base fact caching the results
- * for washington and jefferson after the initial data load.
- */
-}
-start()
-
-/*
- * OUTPUT:
- *
- * loading account information for "washington"
- * washington DID meet conditions for the microsoft-terminated-employees rule.
- * washington did NOT meet conditions for the five-year-tenure rule.
- * loading account information for "jefferson"
- * jefferson did NOT meet conditions for the microsoft-terminated-employees rule.
- * jefferson DID meet conditions for the five-year-tenure rule.
- */
diff --git a/examples/04-fact-dependency.mts b/examples/04-fact-dependency.mts
new file mode 100644
index 00000000..3315528f
--- /dev/null
+++ b/examples/04-fact-dependency.mts
@@ -0,0 +1,159 @@
+/*
+ * This is an advanced example that demonstrates facts with dependencies
+ * on other facts. In addition, it demonstrates facts that load data asynchronously
+ * from outside sources (api's, databases, etc)
+ *
+ * Usage:
+ * node ./examples/04-fact-dependency.js
+ *
+ * For detailed output:
+ * DEBUG=json-rules-engine node ./examples/04-fact-dependency.js
+ */
+
+import "colors";
+import { Engine } from "json-rules-engine";
+import accountClient from "./support/account-api-client.mjs";
+
+async function start() {
+ /**
+ * Setup a new engine
+ */
+ const engine = new Engine();
+
+ /**
+ * Rule for identifying microsoft employees that have been terminated.
+ * - Demonstrates re-using a same fact with different parameters
+ * - Demonstrates calling a base fact, which serves to load data once and reuse later
+ */
+ const microsoftRule = {
+ conditions: {
+ all: [
+ {
+ fact: "account-information",
+ operator: "equal",
+ value: "microsoft",
+ path: "$.company",
+ },
+ {
+ fact: "account-information",
+ operator: "equal",
+ value: "terminated",
+ path: "$.status",
+ },
+ ],
+ },
+ event: { type: "microsoft-terminated-employees" },
+ };
+ engine.addRule(microsoftRule);
+
+ /**
+ * Rule for identifying accounts older than 5 years
+ * - Demonstrates calling a base fact, also shared by the account-information-field fact
+ * - Demonstrates performing computations on data retrieved by base fact
+ */
+ const tenureRule = {
+ conditions: {
+ all: [
+ {
+ fact: "employee-tenure",
+ operator: "greaterThanInclusive",
+ value: 5,
+ params: {
+ unit: "years",
+ },
+ },
+ ],
+ },
+ event: { type: "five-year-tenure" },
+ };
+ engine.addRule(tenureRule);
+
+ /**
+ * Register listeners with the engine for rule success and failure
+ */
+ let facts: Record;
+ engine
+ .on("success", (event) => {
+ console.log(
+ facts.accountId +
+ " DID ".green +
+ "meet conditions for the " +
+ event.type.underline +
+ " rule.",
+ );
+ })
+ .on("failure", (event) => {
+ console.log(
+ facts.accountId +
+ " did " +
+ "NOT".red +
+ " meet conditions for the " +
+ event.type.underline +
+ " rule.",
+ );
+ });
+
+ /**
+ * 'account-information' fact executes an api call and retrieves account data
+ * - Demonstrates facts called only by other facts and never mentioned directly in a rule
+ */
+ engine.addFact("account-information", async (_params, almanac) => {
+ const accountId = await almanac.factValue("accountId");
+ return accountClient.getAccountInformation(accountId);
+ });
+
+ /**
+ * 'employee-tenure' fact retrieves account-information, and computes the duration of employment
+ * since the account was created using 'accountInformation.createdAt'
+ */
+ engine.addFact("employee-tenure", async (params, almanac) => {
+ try {
+ const accountInformation = await almanac.factValue<{ createdAt: string }>(
+ "account-information",
+ );
+ const created = new Date(accountInformation.createdAt);
+ const now = new Date();
+ switch (params.unit) {
+ case "years":
+ return now.getFullYear() - created.getFullYear();
+ case "milliseconds":
+ default:
+ return now.getTime() - created.getTime();
+ }
+ } catch (err) {
+ console.log(err);
+ return undefined;
+ }
+ });
+
+ // first run, using washington's facts
+ console.log("-- FIRST RUN --");
+ facts = { accountId: "washington" };
+ await engine.run(facts);
+
+ console.log("-- SECOND RUN --");
+ // second run, using jefferson's facts; facts & evaluation are independent of the first run
+ facts = { accountId: "jefferson" };
+ await engine.run(facts);
+
+ /*
+ * NOTES:
+ *
+ * - Notice that although a total of 6 conditions were evaluated using
+ * account-information (3 rule conditions x 2 accounts), the account-information api call
+ * is only called twice -- once for each account. This is due to the base fact caching the results
+ * for washington and jefferson after the initial data load.
+ */
+}
+start();
+
+/*
+ * OUTPUT:
+ *
+ * loading account information for "washington"
+ * washington DID meet conditions for the microsoft-terminated-employees rule.
+ * washington did NOT meet conditions for the five-year-tenure rule.
+ * loading account information for "jefferson"
+ * jefferson did NOT meet conditions for the microsoft-terminated-employees rule.
+ * jefferson DID meet conditions for the five-year-tenure rule.
+ */
diff --git a/examples/05-optimizing-runtime-with-fact-priorities.js b/examples/05-optimizing-runtime-with-fact-priorities.js
deleted file mode 100644
index bfb63f6d..00000000
--- a/examples/05-optimizing-runtime-with-fact-priorities.js
+++ /dev/null
@@ -1,101 +0,0 @@
-'use strict'
-/*
- * This is an advanced example that demonstrates using fact priorities to optimize the rules engine.
- *
- * Usage:
- * node ./examples/05-optimizing-runtime-with-fact-priorities.js
- *
- * For detailed output:
- * DEBUG=json-rules-engine node ./examples/05-optimizing-runtime-with-fact-priorities.js
- */
-
-require('colors')
-const { Engine } = require('json-rules-engine')
-const accountClient = require('./support/account-api-client')
-
-async function start () {
- /**
- * Setup a new engine
- */
- const engine = new Engine()
-
- /**
- * - Demonstrates setting high performance (cpu) facts higher than low performing (network call) facts.
- */
- const microsoftRule = {
- conditions: {
- all: [{
- fact: 'account-information',
- operator: 'equal',
- value: true
- }, {
- fact: 'date',
- operator: 'lessThan',
- value: 1467331200000 // unix ts for 2016-07-01; truthy when current date is prior to 2016-07-01
- }]
- },
- event: { type: 'microsoft-employees' }
- }
- engine.addRule(microsoftRule)
-
- /**
- * Register listeners with the engine for rule success and failure
- */
- const facts = { accountId: 'washington' }
- engine
- .on('success', event => {
- console.log(facts.accountId + ' DID '.green + 'meet conditions for the ' + event.type.underline + ' rule.')
- })
- .on('failure', event => {
- console.log(facts.accountId + ' did ' + 'NOT'.red + ' meet conditions for the ' + event.type.underline + ' rule.')
- })
-
- /**
- * Low and High Priorities.
- * Facts that do not have a priority set default to 1
- * @type {Integer} - Facts are run in priority from highest to lowest.
- */
- const HIGH = 100
- const LOW = 1
-
- /**
- * 'account-information' fact executes an api call - network calls are expensive, so
- * we set this fact to be LOW priority; it will only be evaluated after all higher priority facts
- * evaluate truthy
- */
- engine.addFact('account-information', (params, almanac) => {
- // this fact will not be evaluated, because the "date" fact will fail first
- console.log('Checking the "account-information" fact...') // this message will not appear
- return almanac.factValue('accountId')
- .then((accountId) => {
- return accountClient.getAccountInformation(accountId)
- })
- }, { priority: LOW })
-
- /**
- * 'date' fact returns the current unix timestamp in ms.
- * Because this is cheap to compute, we set it to "HIGH" priority
- */
- engine.addFact('date', (params, almanac) => {
- console.log('Checking the "date" fact...')
- return Date.now()
- }, { priority: HIGH })
-
- // define fact(s) known at runtime
- await engine.run()
-}
-start()
-
-/*
- * OUTPUT:
- *
- * Checking the "date" fact first..
- * washington did NOT meet conditions for the microsoft-employees rule.
- */
-
-/*
- * NOTES:
- *
- * - Notice that the "account-information" fact was never evaluated, saving a network call and speeding up
- * the engine by an order of magnitude(or more!). Swap the priorities of the facts to see both run.
- */
diff --git a/examples/05-optimizing-runtime-with-fact-priorities.mts b/examples/05-optimizing-runtime-with-fact-priorities.mts
new file mode 100644
index 00000000..20753623
--- /dev/null
+++ b/examples/05-optimizing-runtime-with-fact-priorities.mts
@@ -0,0 +1,122 @@
+/*
+ * This is an advanced example that demonstrates using fact priorities to optimize the rules engine.
+ *
+ * Usage:
+ * node ./examples/05-optimizing-runtime-with-fact-priorities.js
+ *
+ * For detailed output:
+ * DEBUG=json-rules-engine node ./examples/05-optimizing-runtime-with-fact-priorities.js
+ */
+
+import "colors";
+import { Engine } from "json-rules-engine";
+import accountClient from "./support/account-api-client.mjs";
+
+async function start() {
+ /**
+ * Setup a new engine
+ */
+ const engine = new Engine();
+
+ /**
+ * - Demonstrates setting high performance (cpu) facts higher than low performing (network call) facts.
+ */
+ const microsoftRule = {
+ conditions: {
+ all: [
+ {
+ fact: "account-information",
+ operator: "equal",
+ value: true,
+ },
+ {
+ fact: "date",
+ operator: "lessThan",
+ value: 1467331200000, // unix ts for 2016-07-01; truthy when current date is prior to 2016-07-01
+ },
+ ],
+ },
+ event: { type: "microsoft-employees" },
+ };
+ engine.addRule(microsoftRule);
+
+ /**
+ * Register listeners with the engine for rule success and failure
+ */
+ const facts = { accountId: "washington" };
+ engine
+ .on("success", (event) => {
+ console.log(
+ facts.accountId +
+ " DID ".green +
+ "meet conditions for the " +
+ event.type.underline +
+ " rule.",
+ );
+ })
+ .on("failure", (event) => {
+ console.log(
+ facts.accountId +
+ " did " +
+ "NOT".red +
+ " meet conditions for the " +
+ event.type.underline +
+ " rule.",
+ );
+ });
+
+ /**
+ * Low and High Priorities.
+ * Facts that do not have a priority set default to 1
+ * @type {Integer} - Facts are run in priority from highest to lowest.
+ */
+ const HIGH = 100;
+ const LOW = 1;
+
+ /**
+ * 'account-information' fact executes an api call - network calls are expensive, so
+ * we set this fact to be LOW priority; it will only be evaluated after all higher priority facts
+ * evaluate truthy
+ */
+ engine.addFact(
+ "account-information",
+ async (_params, almanac) => {
+ // this fact will not be evaluated, because the "date" fact will fail first
+ console.log('Checking the "account-information" fact...'); // this message will not appear
+ const accountId = await almanac.factValue("accountId");
+ return accountClient.getAccountInformation(accountId);
+ },
+ { priority: LOW },
+ );
+
+ /**
+ * 'date' fact returns the current unix timestamp in ms.
+ * Because this is cheap to compute, we set it to "HIGH" priority
+ */
+ engine.addFact(
+ "date",
+ () => {
+ console.log('Checking the "date" fact...');
+ return Date.now();
+ },
+ { priority: HIGH },
+ );
+
+ // define fact(s) known at runtime
+ await engine.run();
+}
+start();
+
+/*
+ * OUTPUT:
+ *
+ * Checking the "date" fact first..
+ * washington did NOT meet conditions for the microsoft-employees rule.
+ */
+
+/*
+ * NOTES:
+ *
+ * - Notice that the "account-information" fact was never evaluated, saving a network call and speeding up
+ * the engine by an order of magnitude(or more!). Swap the priorities of the facts to see both run.
+ */
diff --git a/examples/06-custom-operators.js b/examples/06-custom-operators.mts
similarity index 53%
rename from examples/06-custom-operators.js
rename to examples/06-custom-operators.mts
index f9169529..e318201a 100644
--- a/examples/06-custom-operators.js
+++ b/examples/06-custom-operators.mts
@@ -1,4 +1,3 @@
-'use strict'
/*
* This example demonstrates using custom operators.
*
@@ -15,88 +14,94 @@
* DEBUG=json-rules-engine node ./examples/06-custom-operators.js
*/
-require('colors')
-const { Engine } = require('json-rules-engine')
+import "colors";
+import { Engine } from "json-rules-engine";
-async function start () {
+async function start() {
/**
* Setup a new engine
*/
- const engine = new Engine()
+ const engine = new Engine();
/**
* Define a 'startsWith' custom operator, for use in later rules
*/
- engine.addOperator('startsWith', (factValue, jsonValue) => {
- if (!factValue.length) return false
- return factValue[0].toLowerCase() === jsonValue.toLowerCase()
- })
+ engine.addOperator("startsWith", (factValue: string, jsonValue: string) => {
+ if (!factValue.length) return false;
+ return factValue[0].toLowerCase() === jsonValue.toLowerCase();
+ });
/**
* Add rule for detecting words that start with 'a'
*/
const ruleA = {
conditions: {
- all: [{
- fact: 'word',
- operator: 'startsWith',
- value: 'a'
- }]
+ all: [
+ {
+ fact: "word",
+ operator: "startsWith",
+ value: "a",
+ },
+ ],
},
event: {
- type: 'start-with-a'
- }
- }
- engine.addRule(ruleA)
+ type: "start-with-a",
+ },
+ };
+ engine.addRule(ruleA);
/*
- * Add rule for detecting words that start with 'b'
- */
+ * Add rule for detecting words that start with 'b'
+ */
const ruleB = {
conditions: {
- all: [{
- fact: 'word',
- operator: 'startsWith',
- value: 'b'
- }]
+ all: [
+ {
+ fact: "word",
+ operator: "startsWith",
+ value: "b",
+ },
+ ],
},
event: {
- type: 'start-with-b'
- }
- }
- engine.addRule(ruleB)
+ type: "start-with-b",
+ },
+ };
+ engine.addRule(ruleB);
// utility for printing output
- const printEventType = {
- 'start-with-a': 'start with "a"',
- 'start-with-b': 'start with "b"'
- }
+ const printEventType: Record = {
+ "start-with-a": 'start with "a"',
+ "start-with-b": 'start with "b"',
+ };
/**
* Register listeners with the engine for rule success and failure
*/
- let facts
+ let facts: Record;
engine
- .on('success', event => {
- console.log(facts.word + ' DID '.green + printEventType[event.type])
- })
- .on('failure', event => {
- console.log(facts.word + ' did ' + 'NOT'.red + ' ' + printEventType[event.type])
+ .on("success", (event) => {
+ console.log(facts.word + " DID ".green + printEventType[event.type]);
})
+ .on("failure", (event) => {
+ console.log(
+ facts.word + " did " + "NOT".red + " " + printEventType[event.type],
+ );
+ });
/**
* Each run() of the engine executes on an independent set of facts. We'll run twice, once per word
*/
// first run, using 'bacon'
- facts = { word: 'bacon' }
- await engine.run(facts)
+ facts = { word: "bacon" };
+ await engine.run(facts);
// second run, using 'antelope'
- facts = { word: 'antelope' }
- await engine.run(facts)
+ facts = { word: "antelope" };
+ await engine.run(facts);
}
-start()
+start();
/*
* OUTPUT:
diff --git a/examples/07-rule-chaining.js b/examples/07-rule-chaining.js
deleted file mode 100644
index e086cbc5..00000000
--- a/examples/07-rule-chaining.js
+++ /dev/null
@@ -1,126 +0,0 @@
-'use strict'
-/*
- * This is an advanced example demonstrating rules that passed based off the
- * results of other rules by adding runtime facts. It also demonstrates
- * accessing the runtime facts after engine execution.
- *
- * Usage:
- * node ./examples/07-rule-chaining.js
- *
- * For detailed output:
- * DEBUG=json-rules-engine node ./examples/07-rule-chaining.js
- */
-
-require('colors')
-const { Engine } = require('json-rules-engine')
-const { getAccountInformation } = require('./support/account-api-client')
-
-async function start () {
- /**
- * Setup a new engine
- */
- const engine = new Engine()
-
- /**
- * Rule for identifying people who may like screwdrivers
- */
- const drinkRule = {
- conditions: {
- all: [{
- fact: 'drinksOrangeJuice',
- operator: 'equal',
- value: true
- }, {
- fact: 'enjoysVodka',
- operator: 'equal',
- value: true
- }]
- },
- event: { type: 'drinks-screwdrivers' },
- priority: 10, // IMPORTANT! Set a higher priority for the drinkRule, so it runs first
- onSuccess: async function (event, almanac) {
- almanac.addFact('screwdriverAficionado', true)
-
- // asychronous operations can be performed within callbacks
- // engine execution will not proceed until the returned promises is resolved
- const accountId = await almanac.factValue('accountId')
- const accountInfo = await getAccountInformation(accountId)
- almanac.addFact('accountInfo', accountInfo)
- },
- onFailure: function (event, almanac) {
- almanac.addFact('screwdriverAficionado', false)
- }
- }
- engine.addRule(drinkRule)
-
- /**
- * Rule for identifying people who should be invited to a screwdriver social
- * - Only invite people who enjoy screw drivers
- * - Only invite people who are sociable
- */
- const inviteRule = {
- conditions: {
- all: [{
- fact: 'screwdriverAficionado', // this fact value is set when the drinkRule is evaluated
- operator: 'equal',
- value: true
- }, {
- fact: 'isSociable',
- operator: 'equal',
- value: true
- }, {
- fact: 'accountInfo',
- path: '$.company',
- operator: 'equal',
- value: 'microsoft'
- }]
- },
- event: { type: 'invite-to-screwdriver-social' },
- priority: 5 // Set a lower priority for the drinkRule, so it runs later (default: 1)
- }
- engine.addRule(inviteRule)
-
- /**
- * Register listeners with the engine for rule success and failure
- */
- engine
- .on('success', async (event, almanac) => {
- const accountInfo = await almanac.factValue('accountInfo')
- const accountId = await almanac.factValue('accountId')
- console.log(`${accountId}(${accountInfo.company}) ` + 'DID'.green + ` meet conditions for the ${event.type.underline} rule.`)
- })
- .on('failure', async (event, almanac) => {
- const accountId = await almanac.factValue('accountId')
- console.log(`${accountId} did ` + 'NOT'.red + ` meet conditions for the ${event.type.underline} rule.`)
- })
-
- // define fact(s) known at runtime
- let facts = { accountId: 'washington', drinksOrangeJuice: true, enjoysVodka: true, isSociable: true, accountInfo: {} }
-
- // first run, using washington's facts
- let results = await engine.run(facts)
-
- // isScrewdriverAficionado was a fact set by engine.run()
- let isScrewdriverAficionado = results.almanac.factValue('screwdriverAficionado')
- console.log(`${facts.accountId} ${isScrewdriverAficionado ? 'IS'.green : 'IS NOT'.red} a screwdriver aficionado`)
-
- facts = { accountId: 'jefferson', drinksOrangeJuice: true, enjoysVodka: false, isSociable: true, accountInfo: {} }
- results = await engine.run(facts) // second run, using jefferson's facts; facts & evaluation are independent of the first run
-
- isScrewdriverAficionado = await results.almanac.factValue('screwdriverAficionado')
- console.log(`${facts.accountId} ${isScrewdriverAficionado ? 'IS'.green : 'IS NOT'.red} a screwdriver aficionado`)
-}
-
-start()
-
-/*
- * OUTPUT:
- *
- * loading account information for "washington"
- * washington(microsoft) DID meet conditions for the drinks-screwdrivers rule.
- * washington(microsoft) DID meet conditions for the invite-to-screwdriver-social rule.
- * washington IS a screwdriver aficionado
- * jefferson did NOT meet conditions for the drinks-screwdrivers rule.
- * jefferson did NOT meet conditions for the invite-to-screwdriver-social rule.
- * jefferson IS NOT a screwdriver aficionado
- */
diff --git a/examples/07-rule-chaining.mts b/examples/07-rule-chaining.mts
new file mode 100644
index 00000000..cece64da
--- /dev/null
+++ b/examples/07-rule-chaining.mts
@@ -0,0 +1,162 @@
+/*
+ * This is an advanced example demonstrating rules that passed based off the
+ * results of other rules by adding runtime facts. It also demonstrates
+ * accessing the runtime facts after engine execution.
+ *
+ * Usage:
+ * node ./examples/07-rule-chaining.js
+ *
+ * For detailed output:
+ * DEBUG=json-rules-engine node ./examples/07-rule-chaining.js
+ */
+
+import "colors";
+import { Almanac, Engine } from "json-rules-engine";
+import apiClient from "./support/account-api-client.mjs";
+
+async function start() {
+ /**
+ * Setup a new engine
+ */
+ const engine = new Engine();
+
+ /**
+ * Rule for identifying people who may like screwdrivers
+ */
+ const drinkRule = {
+ conditions: {
+ all: [
+ {
+ fact: "drinksOrangeJuice",
+ operator: "equal",
+ value: true,
+ },
+ {
+ fact: "enjoysVodka",
+ operator: "equal",
+ value: true,
+ },
+ ],
+ },
+ event: { type: "drinks-screwdrivers" },
+ priority: 10, // IMPORTANT! Set a higher priority for the drinkRule, so it runs first
+ onSuccess: async function (_event: unknown, almanac: Almanac) {
+ almanac.addFact("screwdriverAficionado", true);
+
+ // asychronous operations can be performed within callbacks
+ // engine execution will not proceed until the returned promises is resolved
+ const accountId = await almanac.factValue("accountId");
+ const accountInfo = await apiClient.getAccountInformation(accountId);
+ almanac.addFact("accountInfo", accountInfo);
+ },
+ onFailure: function (_event: unknown, almanac: Almanac) {
+ almanac.addFact("screwdriverAficionado", false);
+ },
+ };
+ engine.addRule(drinkRule);
+
+ /**
+ * Rule for identifying people who should be invited to a screwdriver social
+ * - Only invite people who enjoy screw drivers
+ * - Only invite people who are sociable
+ */
+ const inviteRule = {
+ conditions: {
+ all: [
+ {
+ fact: "screwdriverAficionado", // this fact value is set when the drinkRule is evaluated
+ operator: "equal",
+ value: true,
+ },
+ {
+ fact: "isSociable",
+ operator: "equal",
+ value: true,
+ },
+ {
+ fact: "accountInfo",
+ path: "$.company",
+ operator: "equal",
+ value: "microsoft",
+ },
+ ],
+ },
+ event: { type: "invite-to-screwdriver-social" },
+ priority: 5, // Set a lower priority for the drinkRule, so it runs later (default: 1)
+ };
+ engine.addRule(inviteRule);
+
+ /**
+ * Register listeners with the engine for rule success and failure
+ */
+ engine
+ .on("success", async (event, almanac) => {
+ const accountInfo = await almanac.factValue<{ company: string }>(
+ "accountInfo",
+ );
+ const accountId = await almanac.factValue("accountId");
+ console.log(
+ `${accountId}(${accountInfo.company}) ` +
+ "DID".green +
+ ` meet conditions for the ${event.type.underline} rule.`,
+ );
+ })
+ .on("failure", async (event, almanac) => {
+ const accountId = await almanac.factValue("accountId");
+ console.log(
+ `${accountId} did ` +
+ "NOT".red +
+ ` meet conditions for the ${event.type.underline} rule.`,
+ );
+ });
+
+ // define fact(s) known at runtime
+ let facts = {
+ accountId: "washington",
+ drinksOrangeJuice: true,
+ enjoysVodka: true,
+ isSociable: true,
+ accountInfo: {},
+ };
+
+ // first run, using washington's facts
+ let results = await engine.run(facts);
+
+ // isScrewdriverAficionado was a fact set by engine.run()
+ let isScrewdriverAficionado = await results.almanac.factValue(
+ "screwdriverAficionado",
+ );
+ console.log(
+ `${facts.accountId} ${isScrewdriverAficionado ? "IS".green : "IS NOT".red} a screwdriver aficionado`,
+ );
+
+ facts = {
+ accountId: "jefferson",
+ drinksOrangeJuice: true,
+ enjoysVodka: false,
+ isSociable: true,
+ accountInfo: {},
+ };
+ results = await engine.run(facts); // second run, using jefferson's facts; facts & evaluation are independent of the first run
+
+ isScrewdriverAficionado = await results.almanac.factValue(
+ "screwdriverAficionado",
+ );
+ console.log(
+ `${facts.accountId} ${isScrewdriverAficionado ? "IS".green : "IS NOT".red} a screwdriver aficionado`,
+ );
+}
+
+start();
+
+/*
+ * OUTPUT:
+ *
+ * loading account information for "washington"
+ * washington(microsoft) DID meet conditions for the drinks-screwdrivers rule.
+ * washington(microsoft) DID meet conditions for the invite-to-screwdriver-social rule.
+ * washington IS a screwdriver aficionado
+ * jefferson did NOT meet conditions for the drinks-screwdrivers rule.
+ * jefferson did NOT meet conditions for the invite-to-screwdriver-social rule.
+ * jefferson IS NOT a screwdriver aficionado
+ */
diff --git a/examples/08-fact-comparison.js b/examples/08-fact-comparison.js
deleted file mode 100644
index 31368098..00000000
--- a/examples/08-fact-comparison.js
+++ /dev/null
@@ -1,137 +0,0 @@
-'use strict'
-/*
- * This is a basic example demonstrating a condition that compares two facts
- *
- * Usage:
- * node ./examples/08-fact-comparison.js
- *
- * For detailed output:
- * DEBUG=json-rules-engine node ./examples/08-fact-comparison.js
- */
-
-require('colors')
-const { Engine } = require('json-rules-engine')
-
-async function start () {
- /**
- * Setup a new engine
- */
- const engine = new Engine()
-
- /**
- * Rule for determining if account has enough money to purchase a $50 gift card product
- *
- * customer-account-balance >= $50 gift card
- */
- const rule = {
- conditions: {
- all: [{
- // extract 'balance' from the 'customer' account type
- fact: 'account',
- path: '$.balance',
- params: {
- accountType: 'customer'
- },
-
- operator: 'greaterThanInclusive', // >=
-
- // "value" in this instance is an object containing a fact definition
- // fact helpers "path" and "params" are supported here as well
- value: {
- fact: 'product',
- path: '$.price',
- params: {
- productId: 'giftCard'
- }
- }
- }]
- },
- event: { type: 'customer-can-afford-gift-card' }
- }
- engine.addRule(rule)
-
- engine.addFact('account', (params, almanac) => {
- // get account list
- return almanac.factValue('accounts')
- .then(accounts => {
- // use "params" to filter down to the type specified, in this case the "customer" account
- const customerAccount = accounts.filter(account => account.type === params.accountType)
- // return the customerAccount object, which "path" will use to pull the "balance" property
- return customerAccount[0]
- })
- })
-
- engine.addFact('product', (params, almanac) => {
- // get product list
- return almanac.factValue('products')
- .then(products => {
- // use "params" to filter down to the product specified, in this case the "giftCard" product
- const product = products.filter(product => product.productId === params.productId)
- // return the product object, which "path" will use to pull the "price" property
- return product[0]
- })
- })
-
- /**
- * Register listeners with the engine for rule success and failure
- */
- let facts
- engine
- .on('success', (event, almanac) => {
- console.log(facts.userId + ' DID '.green + 'meet conditions for the ' + event.type.underline + ' rule.')
- })
- .on('failure', event => {
- console.log(facts.userId + ' did ' + 'NOT'.red + ' meet conditions for the ' + event.type.underline + ' rule.')
- })
-
- // define fact(s) known at runtime
- const productList = {
- products: [
- {
- productId: 'giftCard',
- price: 50
- }, {
- productId: 'widget',
- price: 45
- }, {
- productId: 'widget-plus',
- price: 800
- }
- ]
- }
-
- let userFacts = {
- userId: 'washington',
- accounts: [{
- type: 'customer',
- balance: 500
- }, {
- type: 'partner',
- balance: 0
- }]
- }
-
- // compile facts to be fed to the engine
- facts = Object.assign({}, userFacts, productList)
-
- // first run, user can afford a gift card
- await engine.run(facts)
-
- // second run; a user that cannot afford a gift card
- userFacts = {
- userId: 'jefferson',
- accounts: [{
- type: 'customer',
- balance: 30
- }]
- }
- facts = Object.assign({}, userFacts, productList)
- await engine.run(facts)
-}
-start()
-/*
- * OUTPUT:
- *
- * washington DID meet conditions for the customer-can-afford-gift-card rule.
- * jefferson did NOT meet conditions for the customer-can-afford-gift-card rule.
- */
diff --git a/examples/08-fact-comparison.mts b/examples/08-fact-comparison.mts
new file mode 100644
index 00000000..7c38d42c
--- /dev/null
+++ b/examples/08-fact-comparison.mts
@@ -0,0 +1,159 @@
+/*
+ * This is a basic example demonstrating a condition that compares two facts
+ *
+ * Usage:
+ * node ./examples/08-fact-comparison.js
+ *
+ * For detailed output:
+ * DEBUG=json-rules-engine node ./examples/08-fact-comparison.js
+ */
+
+import "colors";
+import { Engine } from "json-rules-engine";
+
+async function start() {
+ /**
+ * Setup a new engine
+ */
+ const engine = new Engine();
+
+ /**
+ * Rule for determining if account has enough money to purchase a $50 gift card product
+ *
+ * customer-account-balance >= $50 gift card
+ */
+ const rule = {
+ conditions: {
+ all: [
+ {
+ // extract 'balance' from the 'customer' account type
+ fact: "account",
+ path: "$.balance",
+ params: {
+ accountType: "customer",
+ },
+
+ operator: "greaterThanInclusive", // >=
+
+ // "value" in this instance is an object containing a fact definition
+ // fact helpers "path" and "params" are supported here as well
+ value: {
+ fact: "product",
+ path: "$.price",
+ params: {
+ productId: "giftCard",
+ },
+ },
+ },
+ ],
+ },
+ event: { type: "customer-can-afford-gift-card" },
+ };
+ engine.addRule(rule);
+
+ engine.addFact("account", async (params, almanac) => {
+ // get account list
+ const accounts = await almanac.factValue<{ type: string }[]>("accounts");
+ // use "params" to filter down to the type specified, in this case the "customer" account
+ const customerAccount = accounts.filter(
+ (account) => account.type === params.accountType,
+ );
+ // return the customerAccount object, which "path" will use to pull the "balance" property
+ return customerAccount[0];
+ });
+
+ engine.addFact("product", async (params, almanac) => {
+ // get product list
+ const products =
+ await almanac.factValue<{ productId: string }[]>("products");
+ // use "params" to filter down to the product specified, in this case the "giftCard" product
+ const product = products.filter(
+ (product) => product.productId === params.productId,
+ );
+ // return the product object, which "path" will use to pull the "price" property
+ return product[0];
+ });
+
+ /**
+ * Register listeners with the engine for rule success and failure
+ */
+ let facts: Record;
+ engine
+ .on("success", (event) => {
+ console.log(
+ facts.userId +
+ " DID ".green +
+ "meet conditions for the " +
+ event.type.underline +
+ " rule.",
+ );
+ })
+ .on("failure", (event) => {
+ console.log(
+ facts.userId +
+ " did " +
+ "NOT".red +
+ " meet conditions for the " +
+ event.type.underline +
+ " rule.",
+ );
+ });
+
+ // define fact(s) known at runtime
+ const productList = {
+ products: [
+ {
+ productId: "giftCard",
+ price: 50,
+ },
+ {
+ productId: "widget",
+ price: 45,
+ },
+ {
+ productId: "widget-plus",
+ price: 800,
+ },
+ ],
+ };
+
+ let userFacts = {
+ userId: "washington",
+ accounts: [
+ {
+ type: "customer",
+ balance: 500,
+ },
+ {
+ type: "partner",
+ balance: 0,
+ },
+ ],
+ };
+
+ // compile facts to be fed to the engine
+ facts = Object.assign({}, userFacts, productList);
+
+ // first run, user can afford a gift card
+ await engine.run(facts);
+
+ // second run; a user that cannot afford a gift card
+ userFacts = {
+ userId: "jefferson",
+ accounts: [
+ {
+ type: "customer",
+ balance: 30,
+ },
+ ],
+ };
+ facts = Object.assign({}, userFacts, productList);
+ await engine.run(facts);
+}
+start();
+/*
+ * OUTPUT:
+ *
+ * washington DID meet conditions for the customer-can-afford-gift-card rule.
+ * jefferson did NOT meet conditions for the customer-can-afford-gift-card rule.
+ */
diff --git a/examples/09-rule-results.js b/examples/09-rule-results.js
deleted file mode 100644
index d895ffbd..00000000
--- a/examples/09-rule-results.js
+++ /dev/null
@@ -1,98 +0,0 @@
-'use strict'
-/*
- * This is a basic example demonstrating how to leverage the metadata supplied by rule results
- *
- * Usage:
- * node ./examples/09-rule-results.js
- *
- * For detailed output:
- * DEBUG=json-rules-engine node ./examples/09-rule-results.js
- */
-require('colors')
-const { Engine } = require('json-rules-engine')
-
-async function start () {
- /**
- * Setup a new engine
- */
- const engine = new Engine()
-
- // rule for determining honor role student athletes (student has GPA >= 3.5 AND is an athlete)
- engine.addRule({
- conditions: {
- all: [{
- fact: 'athlete',
- operator: 'equal',
- value: true
- }, {
- fact: 'GPA',
- operator: 'greaterThanInclusive',
- value: 3.5
- }]
- },
- event: { // define the event to fire when the conditions evaluate truthy
- type: 'honor-roll',
- params: {
- message: 'Student made the athletics honor-roll'
- }
- },
- name: 'Athlete GPA Rule'
- })
-
- function render (message, ruleResult) {
- // if rule succeeded, render success message
- if (ruleResult.result) {
- return console.log(`${message}`.green)
- }
- // if rule failed, iterate over each failed condition to determine why the student didn't qualify for athletics honor roll
- const detail = ruleResult.conditions.all.filter(condition => !condition.result)
- .map(condition => {
- switch (condition.operator) {
- case 'equal':
- return `was not an ${condition.fact}`
- case 'greaterThanInclusive':
- return `${condition.fact} of ${condition.factResult} was too low`
- default:
- return ''
- }
- }).join(' and ')
- console.log(`${message} ${detail}`.red)
- }
-
- /**
- * On success, retrieve the student's username and print rule name for display purposes, and render
- */
- engine.on('success', (event, almanac, ruleResult) => {
- almanac.factValue('username').then(username => {
- render(`${username.bold} succeeded ${ruleResult.name}! ${event.params.message}`, ruleResult)
- })
- })
-
- /**
- * On failure, retrieve the student's username and print rule name for display purposes, and render
- */
- engine.on('failure', (event, almanac, ruleResult) => {
- almanac.factValue('username').then(username => {
- render(`${username.bold} failed ${ruleResult.name} - `, ruleResult)
- })
- })
-
- // Run the engine for 5 different students
- await Promise.all([
- engine.run({ athlete: false, GPA: 3.9, username: 'joe' }),
- engine.run({ athlete: true, GPA: 3.5, username: 'larry' }),
- engine.run({ athlete: false, GPA: 3.1, username: 'jane' }),
- engine.run({ athlete: true, GPA: 4.0, username: 'janet' }),
- engine.run({ athlete: true, GPA: 1.1, username: 'sarah' })
- ])
-}
-start()
-/*
- * OUTPUT:
- *
- * joe failed Athlete GPA Rule - was not an athlete
- * larry succeeded Athlete GPA Rule! Student made the athletics honor-roll
- * jane failed Athlete GPA Rule - was not an athlete and GPA of 3.1 was too low
- * janet succeeded Athlete GPA Rule! Student made the athletics honor-roll
- * sarah failed Athlete GPA Rule - GPA of 1.1 was too low
- */
diff --git a/examples/09-rule-results.mts b/examples/09-rule-results.mts
new file mode 100644
index 00000000..48a56930
--- /dev/null
+++ b/examples/09-rule-results.mts
@@ -0,0 +1,106 @@
+/*
+ * This is a basic example demonstrating how to leverage the metadata supplied by rule results
+ *
+ * Usage:
+ * node ./examples/09-rule-results.js
+ *
+ * For detailed output:
+ * DEBUG=json-rules-engine node ./examples/09-rule-results.js
+ */
+import "colors";
+import { Engine, NestedCondition, RuleResult } from "json-rules-engine";
+
+async function start() {
+ /**
+ * Setup a new engine
+ */
+ const engine = new Engine();
+
+ // rule for determining honor role student athletes (student has GPA >= 3.5 AND is an athlete)
+ engine.addRule({
+ conditions: {
+ all: [
+ {
+ fact: "athlete",
+ operator: "equal",
+ value: true,
+ },
+ {
+ fact: "GPA",
+ operator: "greaterThanInclusive",
+ value: 3.5,
+ },
+ ],
+ },
+ event: {
+ // define the event to fire when the conditions evaluate truthy
+ type: "honor-roll",
+ params: {
+ message: "Student made the athletics honor-roll",
+ },
+ },
+ name: "Athlete GPA Rule",
+ });
+
+ function render(message: string, ruleResult: RuleResult) {
+ // if rule succeeded, render success message
+ if (ruleResult.result) {
+ return console.log(`${message}`.green);
+ }
+ // if rule failed, iterate over each failed condition to determine why the student didn't qualify for athletics honor roll
+ const detail = (ruleResult.conditions as { all: NestedCondition[] }).all
+ .filter((condition) => !(condition as { result?: boolean }).result)
+ .map((condition) => {
+ switch ((condition as { operator?: string }).operator) {
+ case "equal":
+ return `was not an ${(condition as { fact?: string }).fact}`;
+ case "greaterThanInclusive":
+ return `${(condition as { fact: string }).fact} of ${(condition as { factResult?: unknown }).factResult} was too low`;
+ default:
+ return "";
+ }
+ })
+ .join(" and ");
+ console.log(`${message} ${detail}`.red);
+ }
+
+ /**
+ * On success, retrieve the student's username and print rule name for display purposes, and render
+ */
+ engine.on("success", (event, almanac, ruleResult) => {
+ almanac.factValue("username").then((username) => {
+ render(
+ `${username.bold} succeeded ${ruleResult.name}! ${event.params!.message}`,
+ ruleResult,
+ );
+ });
+ });
+
+ /**
+ * On failure, retrieve the student's username and print rule name for display purposes, and render
+ */
+ engine.on("failure", (_event, almanac, ruleResult) => {
+ almanac.factValue("username").then((username) => {
+ render(`${username.bold} failed ${ruleResult.name} - `, ruleResult);
+ });
+ });
+
+ // Run the engine for 5 different students
+ await Promise.all([
+ engine.run({ athlete: false, GPA: 3.9, username: "joe" }),
+ engine.run({ athlete: true, GPA: 3.5, username: "larry" }),
+ engine.run({ athlete: false, GPA: 3.1, username: "jane" }),
+ engine.run({ athlete: true, GPA: 4.0, username: "janet" }),
+ engine.run({ athlete: true, GPA: 1.1, username: "sarah" }),
+ ]);
+}
+start();
+/*
+ * OUTPUT:
+ *
+ * joe failed Athlete GPA Rule - was not an athlete
+ * larry succeeded Athlete GPA Rule! Student made the athletics honor-roll
+ * jane failed Athlete GPA Rule - was not an athlete and GPA of 3.1 was too low
+ * janet succeeded Athlete GPA Rule! Student made the athletics honor-roll
+ * sarah failed Athlete GPA Rule - GPA of 1.1 was too low
+ */
diff --git a/examples/10-condition-sharing.js b/examples/10-condition-sharing.mts
similarity index 59%
rename from examples/10-condition-sharing.js
rename to examples/10-condition-sharing.mts
index 29fe2e37..836bafe1 100644
--- a/examples/10-condition-sharing.js
+++ b/examples/10-condition-sharing.mts
@@ -1,4 +1,3 @@
-'use strict'
/*
* This is an advanced example demonstrating rules that re-use a condition defined
* in the engine.
@@ -10,32 +9,32 @@
* DEBUG=json-rules-engine node ./examples/10-condition-sharing.js
*/
-require('colors')
-const { Engine } = require('json-rules-engine')
+import "colors";
+import { Engine } from "json-rules-engine";
-async function start () {
+async function start() {
/**
* Setup a new engine
*/
- const engine = new Engine()
+ const engine = new Engine();
/**
* Condition that will be used to determine if a user likes screwdrivers
*/
- engine.setCondition('screwdriverAficionado', {
+ engine.setCondition("screwdriverAficionado", {
all: [
{
- fact: 'drinksOrangeJuice',
- operator: 'equal',
- value: true
+ fact: "drinksOrangeJuice",
+ operator: "equal",
+ value: true,
},
{
- fact: 'enjoysVodka',
- operator: 'equal',
- value: true
- }
- ]
- })
+ fact: "enjoysVodka",
+ operator: "equal",
+ value: true,
+ },
+ ],
+ });
/**
* Rule for identifying people who should be invited to a screwdriver social
@@ -46,18 +45,18 @@ async function start () {
conditions: {
all: [
{
- condition: 'screwdriverAficionado'
+ condition: "screwdriverAficionado",
},
{
- fact: 'isSociable',
- operator: 'equal',
- value: true
- }
- ]
+ fact: "isSociable",
+ operator: "equal",
+ value: true,
+ },
+ ],
},
- event: { type: 'invite-to-screwdriver-social' }
- }
- engine.addRule(inviteRule)
+ event: { type: "invite-to-screwdriver-social" },
+ };
+ engine.addRule(inviteRule);
/**
* Rule for identifying people who should be invited to the other social
@@ -69,65 +68,65 @@ async function start () {
all: [
{
not: {
- condition: 'screwdriverAficionado'
- }
+ condition: "screwdriverAficionado",
+ },
},
{
- fact: 'isSociable',
- operator: 'equal',
- value: true
- }
- ]
+ fact: "isSociable",
+ operator: "equal",
+ value: true,
+ },
+ ],
},
- event: { type: 'invite-to-other-social' }
- }
- engine.addRule(otherInviteRule)
+ event: { type: "invite-to-other-social" },
+ };
+ engine.addRule(otherInviteRule);
/**
* Register listeners with the engine for rule success and failure
*/
engine
- .on('success', async (event, almanac) => {
- const accountId = await almanac.factValue('accountId')
+ .on("success", async (event, almanac) => {
+ const accountId = await almanac.factValue("accountId");
console.log(
`${accountId}` +
- 'DID'.green +
- ` meet conditions for the ${event.type.underline} rule.`
- )
+ "DID".green +
+ ` meet conditions for the ${event.type.underline} rule.`,
+ );
})
- .on('failure', async (event, almanac) => {
- const accountId = await almanac.factValue('accountId')
+ .on("failure", async (event, almanac) => {
+ const accountId = await almanac.factValue("accountId");
console.log(
`${accountId} did ` +
- 'NOT'.red +
- ` meet conditions for the ${event.type.underline} rule.`
- )
- })
+ "NOT".red +
+ ` meet conditions for the ${event.type.underline} rule.`,
+ );
+ });
// define fact(s) known at runtime
- let facts = {
- accountId: 'washington',
+ let facts: Record = {
+ accountId: "washington",
drinksOrangeJuice: true,
enjoysVodka: true,
- isSociable: true
- }
+ isSociable: true,
+ };
// first run, using washington's facts
- await engine.run(facts)
+ await engine.run(facts);
facts = {
- accountId: 'jefferson',
+ accountId: "jefferson",
drinksOrangeJuice: true,
enjoysVodka: false,
isSociable: true,
- accountInfo: {}
- }
+ accountInfo: {},
+ };
// second run, using jefferson's facts; facts & evaluation are independent of the first run
- await engine.run(facts)
+ await engine.run(facts);
}
-start()
+start();
/*
* OUTPUT:
diff --git a/examples/11-using-facts-in-events.js b/examples/11-using-facts-in-events.js
deleted file mode 100644
index 1004e7ee..00000000
--- a/examples/11-using-facts-in-events.js
+++ /dev/null
@@ -1,148 +0,0 @@
-'use strict'
-/*
- * This is an advanced example demonstrating an event that emits the value
- * of a fact in it's parameters.
- *
- * Usage:
- * node ./examples/11-using-facts-in-events.js
- *
- * For detailed output:
- * DEBUG=json-rules-engine node ./examples/11-using-facts-in-events.js
- */
-
-require('colors')
-const { Engine, Fact } = require('json-rules-engine')
-
-async function start () {
- /**
- * Setup a new engine
- */
- const engine = new Engine([], { replaceFactsInEventParams: true })
-
- // in-memory "database"
- let currentHighScore = null
- const currentHighScoreFact = new Fact('currentHighScore', () => currentHighScore)
-
- /**
- * Rule for when you've gotten the high score
- * event will include your score and initials.
- */
- const highScoreRule = {
- conditions: {
- any: [
- {
- fact: 'currentHighScore',
- operator: 'equal',
- value: null
- },
- {
- fact: 'score',
- operator: 'greaterThan',
- value: {
- fact: 'currentHighScore',
- path: '$.score'
- }
- }
- ]
- },
- event: {
- type: 'highscore',
- params: {
- initials: { fact: 'initials' },
- score: { fact: 'score' }
- }
- }
- }
-
- /**
- * Rule for when the game is over and you don't have the high score
- * event will include the previous high score
- */
- const gameOverRule = {
- conditions: {
- all: [
- {
- fact: 'score',
- operator: 'lessThanInclusive',
- value: {
- fact: 'currentHighScore',
- path: '$.score'
- }
- }
- ]
- },
- event: {
- type: 'gameover',
- params: {
- initials: {
- fact: 'currentHighScore',
- path: '$.initials'
- },
- score: {
- fact: 'currentHighScore',
- path: '$.score'
- }
- }
- }
- }
- engine.addRule(highScoreRule)
- engine.addRule(gameOverRule)
- engine.addFact(currentHighScoreFact)
-
- /**
- * Register listeners with the engine for rule success
- */
- engine
- .on('success', async ({ params: { initials, score } }) => {
- console.log(`HIGH SCORE\n${initials} - ${score}`)
- })
- .on('success', ({ type, params }) => {
- if (type === 'highscore') {
- currentHighScore = params
- }
- })
-
- let facts = {
- initials: 'DOG',
- score: 968
- }
-
- // first run, without a high score
- await engine.run(facts)
-
- console.log('\n')
-
- // new player
- facts = {
- initials: 'AAA',
- score: 500
- }
-
- // new player hasn't gotten the high score yet
- await engine.run(facts)
-
- console.log('\n')
-
- facts = {
- initials: 'AAA',
- score: 1000
- }
-
- // second run, with a high score
- await engine.run(facts)
-}
-
-start()
-
-/*
- * OUTPUT:
- *
- * NEW SCORE:
- * DOG - 968
- *
- * HIGH SCORE:
- * DOG - 968
- *
- * HIGH SCORE:
- * AAA - 1000
- */
diff --git a/examples/11-using-facts-in-events.mts b/examples/11-using-facts-in-events.mts
new file mode 100644
index 00000000..6a8f3c41
--- /dev/null
+++ b/examples/11-using-facts-in-events.mts
@@ -0,0 +1,157 @@
+/*
+ * This is an advanced example demonstrating an event that emits the value
+ * of a fact in it's parameters.
+ *
+ * Usage:
+ * node ./examples/11-using-facts-in-events.js
+ *
+ * For detailed output:
+ * DEBUG=json-rules-engine node ./examples/11-using-facts-in-events.js
+ */
+
+import "colors";
+import { Engine, Fact } from "json-rules-engine";
+
+async function start() {
+ /**
+ * Setup a new engine
+ */
+ const engine = new Engine([], { replaceFactsInEventParams: true });
+
+ // in-memory "database"
+ let currentHighScore: { initials: string; score: number } | null = null;
+ const currentHighScoreFact = new Fact(
+ "currentHighScore",
+ () => currentHighScore,
+ );
+
+ /**
+ * Rule for when you've gotten the high score
+ * event will include your score and initials.
+ */
+ const highScoreRule = {
+ conditions: {
+ any: [
+ {
+ fact: "currentHighScore",
+ operator: "equal",
+ value: null,
+ },
+ {
+ fact: "score",
+ operator: "greaterThan",
+ value: {
+ fact: "currentHighScore",
+ path: "$.score",
+ },
+ },
+ ],
+ },
+ event: {
+ type: "highscore",
+ params: {
+ initials: { fact: "initials" },
+ score: { fact: "score" },
+ },
+ },
+ };
+
+ /**
+ * Rule for when the game is over and you don't have the high score
+ * event will include the previous high score
+ */
+ const gameOverRule = {
+ conditions: {
+ all: [
+ {
+ fact: "score",
+ operator: "lessThanInclusive",
+ value: {
+ fact: "currentHighScore",
+ path: "$.score",
+ },
+ },
+ ],
+ },
+ event: {
+ type: "gameover",
+ params: {
+ initials: {
+ fact: "currentHighScore",
+ path: "$.initials",
+ },
+ score: {
+ fact: "currentHighScore",
+ path: "$.score",
+ },
+ },
+ },
+ };
+ engine.addRule(highScoreRule);
+ engine.addRule(gameOverRule);
+ engine.addFact(currentHighScoreFact);
+
+ /**
+ * Register listeners with the engine for rule success
+ */
+ engine
+ .on(
+ "success",
+ async ({
+ params: { initials, score },
+ }: {
+ params: { initials: string; score: number };
+ }) => {
+ console.log(`HIGH SCORE\n${initials} - ${score}`);
+ },
+ )
+ .on("success", ({ type, params }) => {
+ if (type === "highscore") {
+ currentHighScore = params as { initials: string; score: number };
+ }
+ });
+
+ let facts = {
+ initials: "DOG",
+ score: 968,
+ };
+
+ // first run, without a high score
+ await engine.run(facts);
+
+ console.log("\n");
+
+ // new player
+ facts = {
+ initials: "AAA",
+ score: 500,
+ };
+
+ // new player hasn't gotten the high score yet
+ await engine.run(facts);
+
+ console.log("\n");
+
+ facts = {
+ initials: "AAA",
+ score: 1000,
+ };
+
+ // second run, with a high score
+ await engine.run(facts);
+}
+
+start();
+
+/*
+ * OUTPUT:
+ *
+ * NEW SCORE:
+ * DOG - 968
+ *
+ * HIGH SCORE:
+ * DOG - 968
+ *
+ * HIGH SCORE:
+ * AAA - 1000
+ */
diff --git a/examples/12-using-custom-almanac.js b/examples/12-using-custom-almanac.js
deleted file mode 100644
index c94c3981..00000000
--- a/examples/12-using-custom-almanac.js
+++ /dev/null
@@ -1,94 +0,0 @@
-'use strict'
-
-require('colors')
-const { Almanac, Engine } = require('json-rules-engine')
-
-/**
- * Almanac that support piping values through named functions
- */
-class PipedAlmanac extends Almanac {
- constructor (options) {
- super(options)
- this.pipes = new Map()
- }
-
- addPipe (name, pipe) {
- this.pipes.set(name, pipe)
- }
-
- factValue (factId, params, path) {
- let pipes = []
- if (params && 'pipes' in params && Array.isArray(params.pipes)) {
- pipes = params.pipes
- delete params.pipes
- }
- return super.factValue(factId, params, path).then(value => {
- return pipes.reduce((value, pipeName) => {
- const pipe = this.pipes.get(pipeName)
- if (pipe) {
- return pipe(value)
- }
- return value
- }, value)
- })
- }
-}
-
-async function start () {
- const engine = new Engine()
- .addRule({
- conditions: {
- all: [
- {
- fact: 'age',
- params: {
- // the addOne pipe adds one to the value
- pipes: ['addOne']
- },
- operator: 'greaterThanInclusive',
- value: 21
- }
- ]
- },
- event: {
- type: 'Over 21(ish)'
- }
- })
-
- engine.on('success', async (event, almanac) => {
- const name = await almanac.factValue('name')
- const age = await almanac.factValue('age')
- console.log(`${name} is ${age} years old and ${'is'.green} ${event.type}`)
- })
-
- engine.on('failure', async (event, almanac) => {
- const name = await almanac.factValue('name')
- const age = await almanac.factValue('age')
- console.log(`${name} is ${age} years old and ${'is not'.red} ${event.type}`)
- })
-
- const createAlmanacWithPipes = () => {
- const almanac = new PipedAlmanac()
- almanac.addPipe('addOne', (v) => v + 1)
- return almanac
- }
-
- // first run Bob who is less than 20
- await engine.run({ name: 'Bob', age: 19 }, { almanac: createAlmanacWithPipes() })
-
- // second run Alice who is 21
- await engine.run({ name: 'Alice', age: 21 }, { almanac: createAlmanacWithPipes() })
-
- // third run Chad who is 20
- await engine.run({ name: 'Chad', age: 20 }, { almanac: createAlmanacWithPipes() })
-}
-
-start()
-
-/*
- * OUTPUT:
- *
- * Bob is 19 years old and is not Over 21(ish)
- * Alice is 21 years old and is Over 21(ish)
- * Chad is 20 years old and is Over 21(ish)
- */
diff --git a/examples/12-using-custom-almanac.mts b/examples/12-using-custom-almanac.mts
new file mode 100644
index 00000000..b1743c0b
--- /dev/null
+++ b/examples/12-using-custom-almanac.mts
@@ -0,0 +1,104 @@
+import "colors";
+import { Almanac, Engine } from "json-rules-engine";
+
+type Pipe = (value: T) => unknown;
+
+/**
+ * Almanac that support piping values through named functions
+ */
+class PipedAlmanac extends Almanac {
+ pipes = new Map>();
+
+ addPipe(name: string, pipe: Pipe) {
+ this.pipes.set(name, pipe as Pipe);
+ }
+
+ async factValue(
+ factId: string,
+ params?: Record,
+ path?: string,
+ ) {
+ let pipes: string[] = [];
+ if (params && "pipes" in params && Array.isArray(params.pipes)) {
+ pipes = params.pipes;
+ delete params.pipes;
+ }
+ const value = await super.factValue(factId, params, path);
+ return pipes.reduce((value, pipeName) => {
+ const pipe = this.pipes.get(pipeName);
+ if (pipe) {
+ return pipe(value);
+ }
+ return value;
+ }, value) as T;
+ }
+}
+
+async function start() {
+ const engine = new Engine().addRule({
+ conditions: {
+ all: [
+ {
+ fact: "age",
+ params: {
+ // the addOne pipe adds one to the value
+ pipes: ["addOne"],
+ },
+ operator: "greaterThanInclusive",
+ value: 21,
+ },
+ ],
+ },
+ event: {
+ type: "Over 21(ish)",
+ },
+ });
+
+ engine.on("success", async (event, almanac) => {
+ const name = await almanac.factValue("name");
+ const age = await almanac.factValue("age");
+ console.log(`${name} is ${age} years old and ${"is".green} ${event.type}`);
+ });
+
+ engine.on("failure", async (event, almanac) => {
+ const name = await almanac.factValue("name");
+ const age = await almanac.factValue("age");
+ console.log(
+ `${name} is ${age} years old and ${"is not".red} ${event.type}`,
+ );
+ });
+
+ const createAlmanacWithPipes = () => {
+ const almanac = new PipedAlmanac();
+ almanac.addPipe("addOne", (v: number) => v + 1);
+ return almanac;
+ };
+
+ // first run Bob who is less than 20
+ await engine.run(
+ { name: "Bob", age: 19 },
+ { almanac: createAlmanacWithPipes() },
+ );
+
+ // second run Alice who is 21
+ await engine.run(
+ { name: "Alice", age: 21 },
+ { almanac: createAlmanacWithPipes() },
+ );
+
+ // third run Chad who is 20
+ await engine.run(
+ { name: "Chad", age: 20 },
+ { almanac: createAlmanacWithPipes() },
+ );
+}
+
+start();
+
+/*
+ * OUTPUT:
+ *
+ * Bob is 19 years old and is not Over 21(ish)
+ * Alice is 21 years old and is Over 21(ish)
+ * Chad is 20 years old and is Over 21(ish)
+ */
diff --git a/examples/13-using-operator-decorators.js b/examples/13-using-operator-decorators.js
deleted file mode 100644
index 413271eb..00000000
--- a/examples/13-using-operator-decorators.js
+++ /dev/null
@@ -1,97 +0,0 @@
-'use strict'
-/*
- * This example demonstrates using operator decorators.
- *
- * In this example, a fact contains a list of strings and we want to check if any of these are valid.
- *
- * Usage:
- * node ./examples/12-using-operator-decorators.js
- *
- * For detailed output:
- * DEBUG=json-rules-engine node ./examples/12-using-operator-decorators.js
- */
-
-require('colors')
-const { Engine } = require('json-rules-engine')
-
-async function start () {
- /**
- * Setup a new engine
- */
- const engine = new Engine()
-
- /**
- * Add a rule for validating a tag (fact)
- * against a set of tags that are valid (also a fact)
- */
- const validTags = {
- conditions: {
- all: [{
- fact: 'tags',
- operator: 'everyFact:in',
- value: { fact: 'validTags' }
- }]
- },
- event: {
- type: 'valid tags'
- }
- }
-
- engine.addRule(validTags)
-
- engine.addFact('validTags', ['dev', 'staging', 'load', 'prod'])
-
- let facts
-
- engine
- .on('success', event => {
- console.log(facts.tags.join(', ') + ' WERE'.green + ' all ' + event.type)
- })
- .on('failure', event => {
- console.log(facts.tags.join(', ') + ' WERE NOT'.red + ' all ' + event.type)
- })
-
- // first run with valid tags
- facts = { tags: ['dev', 'prod'] }
- await engine.run(facts)
-
- // second run with an invalid tag
- facts = { tags: ['dev', 'deleted'] }
- await engine.run(facts)
-
- // add a new decorator to allow for a case-insensitive match
- engine.addOperatorDecorator('caseInsensitive', (factValue, jsonValue, next) => {
- return next(factValue.toLowerCase(), jsonValue.toLowerCase())
- })
-
- // new rule for case-insensitive validation
- const caseInsensitiveValidTags = {
- conditions: {
- all: [{
- fact: 'tags',
- // everyFact has someValue that caseInsensitive is equal
- operator: 'everyFact:someValue:caseInsensitive:equal',
- value: { fact: 'validTags' }
- }]
- },
- event: {
- type: 'valid tags (case insensitive)'
- }
- }
-
- engine.addRule(caseInsensitiveValidTags)
-
- // third run with a tag that is valid if case insensitive
- facts = { tags: ['dev', 'PROD'] }
- await engine.run(facts)
-}
-start()
-
-/*
- * OUTPUT:
- *
- * dev, prod WERE all valid tags
- * dev, deleted WERE NOT all valid tags
- * dev, PROD WERE NOT all valid tags
- * dev, PROD WERE all valid tags (case insensitive)
- */
diff --git a/examples/13-using-operator-decorators.mts b/examples/13-using-operator-decorators.mts
new file mode 100644
index 00000000..60b34f6b
--- /dev/null
+++ b/examples/13-using-operator-decorators.mts
@@ -0,0 +1,105 @@
+/*
+ * This example demonstrates using operator decorators.
+ *
+ * In this example, a fact contains a list of strings and we want to check if any of these are valid.
+ *
+ * Usage:
+ * node ./examples/12-using-operator-decorators.js
+ *
+ * For detailed output:
+ * DEBUG=json-rules-engine node ./examples/12-using-operator-decorators.js
+ */
+
+import "colors";
+import { Engine } from "json-rules-engine";
+
+async function start() {
+ /**
+ * Setup a new engine
+ */
+ const engine = new Engine();
+
+ /**
+ * Add a rule for validating a tag (fact)
+ * against a set of tags that are valid (also a fact)
+ */
+ const validTags = {
+ conditions: {
+ all: [
+ {
+ fact: "tags",
+ operator: "everyFact:in",
+ value: { fact: "validTags" },
+ },
+ ],
+ },
+ event: {
+ type: "valid tags",
+ },
+ };
+
+ engine.addRule(validTags);
+
+ engine.addFact("validTags", ["dev", "staging", "load", "prod"]);
+
+ let facts: { tags: string[] };
+
+ engine
+ .on("success", (event) => {
+ console.log(facts.tags.join(", ") + " WERE".green + " all " + event.type);
+ })
+ .on("failure", (event) => {
+ console.log(
+ facts.tags.join(", ") + " WERE NOT".red + " all " + event.type,
+ );
+ });
+
+ // first run with valid tags
+ facts = { tags: ["dev", "prod"] };
+ await engine.run(facts);
+
+ // second run with an invalid tag
+ facts = { tags: ["dev", "deleted"] };
+ await engine.run(facts);
+
+ // add a new decorator to allow for a case-insensitive match
+ engine.addOperatorDecorator(
+ "caseInsensitive",
+ (factValue: string, jsonValue: string, next) => {
+ return next(factValue.toLowerCase(), jsonValue.toLowerCase());
+ },
+ );
+
+ // new rule for case-insensitive validation
+ const caseInsensitiveValidTags = {
+ conditions: {
+ all: [
+ {
+ fact: "tags",
+ // everyFact has someValue that caseInsensitive is equal
+ operator: "everyFact:someValue:caseInsensitive:equal",
+ value: { fact: "validTags" },
+ },
+ ],
+ },
+ event: {
+ type: "valid tags (case insensitive)",
+ },
+ };
+
+ engine.addRule(caseInsensitiveValidTags);
+
+ // third run with a tag that is valid if case insensitive
+ facts = { tags: ["dev", "PROD"] };
+ await engine.run(facts);
+}
+start();
+
+/*
+ * OUTPUT:
+ *
+ * dev, prod WERE all valid tags
+ * dev, deleted WERE NOT all valid tags
+ * dev, PROD WERE NOT all valid tags
+ * dev, PROD WERE all valid tags (case insensitive)
+ */
diff --git a/examples/package.json b/examples/package.json
index 1758ee5e..231cd7da 100644
--- a/examples/package.json
+++ b/examples/package.json
@@ -5,11 +5,13 @@
"main": "",
"private": true,
"scripts": {
- "all": "for i in *.js; do node $i; done;"
+ "all": "for i in *.mts; do tsx $i; done;"
},
"author": "Cache Hamm ",
"license": "ISC",
"dependencies": {
- "json-rules-engine": "../"
+ "colors": "^1.4.0",
+ "json-rules-engine": "../",
+ "tsx": "^4.19.1"
}
}
diff --git a/examples/support/account-api-client.js b/examples/support/account-api-client.js
deleted file mode 100644
index 05798998..00000000
--- a/examples/support/account-api-client.js
+++ /dev/null
@@ -1,39 +0,0 @@
-'use strict'
-
-require('colors')
-
-const accountData = {
- washington: {
- company: 'microsoft',
- status: 'terminated',
- ptoDaysTaken: ['2016-12-25', '2016-04-01'],
- createdAt: '2012-02-14'
- },
- jefferson: {
- company: 'apple',
- status: 'terminated',
- ptoDaysTaken: ['2015-01-25'],
- createdAt: '2005-04-03'
- },
- lincoln: {
- company: 'microsoft',
- status: 'active',
- ptoDaysTaken: ['2016-02-21', '2016-12-25', '2016-03-28'],
- createdAt: '2015-06-26'
- }
-}
-
-/**
- * mock api client for retrieving account information
- */
-module.exports = {
- getAccountInformation: (accountId) => {
- const message = 'loading account information for "' + accountId + '"'
- console.log(message.dim)
- return new Promise((resolve, reject) => {
- setImmediate(() => {
- resolve(accountData[accountId])
- })
- })
- }
-}
diff --git a/examples/support/account-api-client.mts b/examples/support/account-api-client.mts
new file mode 100644
index 00000000..ea5fbe12
--- /dev/null
+++ b/examples/support/account-api-client.mts
@@ -0,0 +1,37 @@
+import "colors";
+
+const accountData: Record = {
+ washington: {
+ company: "microsoft",
+ status: "terminated",
+ ptoDaysTaken: ["2016-12-25", "2016-04-01"],
+ createdAt: "2012-02-14",
+ },
+ jefferson: {
+ company: "apple",
+ status: "terminated",
+ ptoDaysTaken: ["2015-01-25"],
+ createdAt: "2005-04-03",
+ },
+ lincoln: {
+ company: "microsoft",
+ status: "active",
+ ptoDaysTaken: ["2016-02-21", "2016-12-25", "2016-03-28"],
+ createdAt: "2015-06-26",
+ },
+};
+
+/**
+ * mock api client for retrieving account information
+ */
+export default {
+ getAccountInformation: (accountId: string) => {
+ const message = 'loading account information for "' + accountId + '"';
+ console.log(message.dim);
+ return new Promise((resolve) => {
+ setImmediate(() => {
+ resolve(accountData[accountId]);
+ });
+ });
+ },
+};
diff --git a/package.json b/package.json
index 8271b7a0..bd4c696b 100644
--- a/package.json
+++ b/package.json
@@ -2,16 +2,19 @@
"name": "json-rules-engine",
"version": "7.0.0-alpha.1",
"description": "Rules Engine expressed in simple json",
- "main": "dist/index.js",
+ "main": "dist/index.cjs",
+ "module": "dist/index.js",
"types": "types/index.d.ts",
+ "type": "module",
+ "engines": {
+ "node": ">=18.0.0"
+ },
"scripts": {
- "test": "mocha && npm run lint --silent && npm run test:types",
- "test:types": "tsd",
- "lint": "standard --verbose --env mocha | snazzy || true",
- "lint:fix": "standard --fix --env mocha",
- "prepublishOnly": "npm run build",
- "build": "babel --stage 1 -d dist/ src/",
- "watch": "babel --watch --stage 1 -d dist/ src",
+ "test": "vitest --typecheck",
+ "lint": "eslint",
+ "format": "prettier -w .",
+ "build": "tsup",
+ "watch": "tsup --watch",
"examples": "./test/support/example_runner.sh"
},
"repository": {
@@ -26,37 +29,6 @@
"publishConfig": {
"tag": "next"
},
- "standard": {
- "parser": "babel-eslint",
- "ignore": [
- "/dist",
- "/examples/node_modules"
- ],
- "globals": [
- "context",
- "xcontext",
- "describe",
- "xdescribe",
- "it",
- "xit",
- "before",
- "beforeEach",
- "expect",
- "factories"
- ]
- },
- "mocha": {
- "require": [
- "babel-core/register",
- "babel-polyfill"
- ],
- "file": "./test/support/bootstrap.js",
- "checkLeaks": true,
- "recursive": true,
- "globals": [
- "expect"
- ]
- },
"author": "Cache Hamm ",
"contributors": [
"Chris Pardy "
@@ -67,31 +39,22 @@
},
"homepage": "https://github.com/cachecontrol/json-rules-engine",
"devDependencies": {
- "babel-cli": "6.26.0",
- "babel-core": "6.26.3",
- "babel-eslint": "10.1.0",
- "babel-loader": "8.2.2",
- "babel-polyfill": "6.26.0",
- "babel-preset-es2015": "~6.24.1",
- "babel-preset-stage-0": "~6.24.1",
- "babel-register": "6.26.0",
- "chai": "^4.3.4",
- "chai-as-promised": "^7.1.1",
- "colors": "~1.4.0",
- "dirty-chai": "2.0.1",
+ "@eslint/js": "^9.13.0",
+ "eslint": "^9.13.0",
+ "globals": "^15.11.0",
"lodash": "4.17.21",
- "mocha": "^8.4.0",
"perfy": "^1.1.5",
- "sinon": "^11.1.1",
- "sinon-chai": "^3.7.0",
- "snazzy": "^9.0.0",
- "standard": "^16.0.3",
- "tsd": "^0.17.0"
+ "prettier": "^3.3.3",
+ "tsd": "^0.17.0",
+ "tsup": "^8.3.0",
+ "typescript": "^5.6.3",
+ "typescript-eslint": "^8.11.0",
+ "vitest": "^2.1.3"
},
"dependencies": {
"clone": "^2.1.2",
- "eventemitter2": "^6.4.4",
+ "eventemitter2": "^6.4.9",
"hash-it": "^6.0.0",
- "jsonpath-plus": "^7.2.0"
+ "jsonpath-plus": "^10.0.0"
}
}
diff --git a/src/almanac.js b/src/almanac.js
deleted file mode 100644
index effc7329..00000000
--- a/src/almanac.js
+++ /dev/null
@@ -1,186 +0,0 @@
-'use strict'
-
-import Fact from './fact'
-import { UndefinedFactError } from './errors'
-import debug from './debug'
-
-import { JSONPath } from 'jsonpath-plus'
-
-function defaultPathResolver (value, path) {
- return JSONPath({ path, json: value, wrap: false })
-}
-
-/**
- * Fact results lookup
- * Triggers fact computations and saves the results
- * A new almanac is used for every engine run()
- */
-export default class Almanac {
- constructor (options = {}) {
- this.factMap = new Map()
- this.factResultsCache = new Map() // { cacheKey: Promise }
- this.allowUndefinedFacts = Boolean(options.allowUndefinedFacts)
- this.pathResolver = options.pathResolver || defaultPathResolver
- this.events = { success: [], failure: [] }
- this.ruleResults = []
- }
-
- /**
- * Adds a success event
- * @param {Object} event
- */
- addEvent (event, outcome) {
- if (!outcome) throw new Error('outcome required: "success" | "failure"]')
- this.events[outcome].push(event)
- }
-
- /**
- * retrieve successful events
- */
- getEvents (outcome = '') {
- if (outcome) return this.events[outcome]
- return this.events.success.concat(this.events.failure)
- }
-
- /**
- * Adds a rule result
- * @param {Object} event
- */
- addResult (ruleResult) {
- this.ruleResults.push(ruleResult)
- }
-
- /**
- * retrieve successful events
- */
- getResults () {
- return this.ruleResults
- }
-
- /**
- * Retrieve fact by id, raising an exception if it DNE
- * @param {String} factId
- * @return {Fact}
- */
- _getFact (factId) {
- return this.factMap.get(factId)
- }
-
- /**
- * Registers fact with the almanac
- * @param {[type]} fact [description]
- */
- _addConstantFact (fact) {
- this.factMap.set(fact.id, fact)
- this._setFactValue(fact, {}, fact.value)
- }
-
- /**
- * Sets the computed value of a fact
- * @param {Fact} fact
- * @param {Object} params - values for differentiating this fact value from others, used for cache key
- * @param {Mixed} value - computed value
- */
- _setFactValue (fact, params, value) {
- const cacheKey = fact.getCacheKey(params)
- const factValue = Promise.resolve(value)
- if (cacheKey) {
- this.factResultsCache.set(cacheKey, factValue)
- }
- return factValue
- }
-
- /**
- * Add a fact definition to the engine. Facts are called by rules as they are evaluated.
- * @param {object|Fact} id - fact identifier or instance of Fact
- * @param {function} definitionFunc - function to be called when computing the fact value for a given rule
- * @param {Object} options - options to initialize the fact with. used when "id" is not a Fact instance
- */
- addFact (id, valueOrMethod, options) {
- let factId = id
- let fact
- if (id instanceof Fact) {
- factId = id.id
- fact = id
- } else {
- fact = new Fact(id, valueOrMethod, options)
- }
- debug('almanac::addFact', { id: factId })
- this.factMap.set(factId, fact)
- if (fact.isConstant()) {
- this._setFactValue(fact, {}, fact.value)
- }
- return this
- }
-
- /**
- * Adds a constant fact during runtime. Can be used mid-run() to add additional information
- * @deprecated use addFact
- * @param {String} fact - fact identifier
- * @param {Mixed} value - constant value of the fact
- */
- addRuntimeFact (factId, value) {
- debug('almanac::addRuntimeFact', { id: factId })
- const fact = new Fact(factId, value)
- return this._addConstantFact(fact)
- }
-
- /**
- * Returns the value of a fact, based on the given parameters. Utilizes the 'almanac' maintained
- * by the engine, which cache's fact computations based on parameters provided
- * @param {string} factId - fact identifier
- * @param {Object} params - parameters to feed into the fact. By default, these will also be used to compute the cache key
- * @param {String} path - object
- * @return {Promise} a promise which will resolve with the fact computation.
- */
- factValue (factId, params = {}, path = '') {
- let factValuePromise
- const fact = this._getFact(factId)
- if (fact === undefined) {
- if (this.allowUndefinedFacts) {
- return Promise.resolve(undefined)
- } else {
- return Promise.reject(new UndefinedFactError(`Undefined fact: ${factId}`))
- }
- }
- if (fact.isConstant()) {
- factValuePromise = Promise.resolve(fact.calculate(params, this))
- } else {
- const cacheKey = fact.getCacheKey(params)
- const cacheVal = cacheKey && this.factResultsCache.get(cacheKey)
- if (cacheVal) {
- factValuePromise = Promise.resolve(cacheVal)
- debug('almanac::factValue cache hit for fact', { id: factId })
- } else {
- debug('almanac::factValue cache miss, calculating', { id: factId })
- factValuePromise = this._setFactValue(fact, params, fact.calculate(params, this))
- }
- }
- if (path) {
- debug('condition::evaluate extracting object', { property: path })
- return factValuePromise
- .then(factValue => {
- if (factValue != null && typeof factValue === 'object') {
- const pathValue = this.pathResolver(factValue, path)
- debug('condition::evaluate extracting object', { property: path, received: pathValue })
- return pathValue
- } else {
- debug('condition::evaluate could not compute object path of non-object', { path, factValue, type: typeof factValue })
- return factValue
- }
- })
- }
-
- return factValuePromise
- }
-
- /**
- * Interprets value as either a primitive, or if a fact, retrieves the fact value
- */
- getValue (value) {
- if (value != null && typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, 'fact')) { // value = { fact: 'xyz' }
- return this.factValue(value.fact, value.params, value.path)
- }
- return Promise.resolve(value)
- }
-}
diff --git a/src/almanac.mjs b/src/almanac.mjs
new file mode 100644
index 00000000..9963406d
--- /dev/null
+++ b/src/almanac.mjs
@@ -0,0 +1,200 @@
+import Fact from "./fact.mjs";
+import { UndefinedFactError } from "./errors.mjs";
+import debug from "./debug.mjs";
+
+import { JSONPath } from "jsonpath-plus";
+
+function defaultPathResolver(value, path) {
+ return JSONPath({ path, json: value, wrap: false });
+}
+
+/**
+ * Fact results lookup
+ * Triggers fact computations and saves the results
+ * A new almanac is used for every engine run()
+ */
+export default class Almanac {
+ constructor(options = {}) {
+ this.factMap = new Map();
+ this.factResultsCache = new Map(); // { cacheKey: Promise }
+ this.allowUndefinedFacts = Boolean(options.allowUndefinedFacts);
+ this.pathResolver = options.pathResolver || defaultPathResolver;
+ this.events = { success: [], failure: [] };
+ this.ruleResults = [];
+ }
+
+ /**
+ * Adds a success event
+ * @param {Object} event
+ */
+ addEvent(event, outcome) {
+ if (!outcome) throw new Error('outcome required: "success" | "failure"]');
+ this.events[outcome].push(event);
+ }
+
+ /**
+ * retrieve successful events
+ */
+ getEvents(outcome = "") {
+ if (outcome) return this.events[outcome];
+ return this.events.success.concat(this.events.failure);
+ }
+
+ /**
+ * Adds a rule result
+ * @param {Object} event
+ */
+ addResult(ruleResult) {
+ this.ruleResults.push(ruleResult);
+ }
+
+ /**
+ * retrieve successful events
+ */
+ getResults() {
+ return this.ruleResults;
+ }
+
+ /**
+ * Retrieve fact by id, raising an exception if it DNE
+ * @param {String} factId
+ * @return {Fact}
+ */
+ _getFact(factId) {
+ return this.factMap.get(factId);
+ }
+
+ /**
+ * Registers fact with the almanac
+ * @param {[type]} fact [description]
+ */
+ _addConstantFact(fact) {
+ this.factMap.set(fact.id, fact);
+ this._setFactValue(fact, {}, fact.value);
+ }
+
+ /**
+ * Sets the computed value of a fact
+ * @param {Fact} fact
+ * @param {Object} params - values for differentiating this fact value from others, used for cache key
+ * @param {Mixed} value - computed value
+ */
+ _setFactValue(fact, params, value) {
+ const cacheKey = fact.getCacheKey(params);
+ const factValue = Promise.resolve(value);
+ if (cacheKey) {
+ this.factResultsCache.set(cacheKey, factValue);
+ }
+ return factValue;
+ }
+
+ /**
+ * Add a fact definition to the engine. Facts are called by rules as they are evaluated.
+ * @param {object|Fact} id - fact identifier or instance of Fact
+ * @param {function} definitionFunc - function to be called when computing the fact value for a given rule
+ * @param {Object} options - options to initialize the fact with. used when "id" is not a Fact instance
+ */
+ addFact(id, valueOrMethod, options) {
+ let factId = id;
+ let fact;
+ if (id instanceof Fact) {
+ factId = id.id;
+ fact = id;
+ } else {
+ fact = new Fact(id, valueOrMethod, options);
+ }
+ debug("almanac::addFact", { id: factId });
+ this.factMap.set(factId, fact);
+ if (fact.isConstant()) {
+ this._setFactValue(fact, {}, fact.value);
+ }
+ return this;
+ }
+
+ /**
+ * Adds a constant fact during runtime. Can be used mid-run() to add additional information
+ * @deprecated use addFact
+ * @param {String} fact - fact identifier
+ * @param {Mixed} value - constant value of the fact
+ */
+ addRuntimeFact(factId, value) {
+ debug("almanac::addRuntimeFact", { id: factId });
+ const fact = new Fact(factId, value);
+ return this._addConstantFact(fact);
+ }
+
+ /**
+ * Returns the value of a fact, based on the given parameters. Utilizes the 'almanac' maintained
+ * by the engine, which cache's fact computations based on parameters provided
+ * @param {string} factId - fact identifier
+ * @param {Object} params - parameters to feed into the fact. By default, these will also be used to compute the cache key
+ * @param {String} path - object
+ * @return {Promise} a promise which will resolve with the fact computation.
+ */
+ factValue(factId, params = {}, path = "") {
+ let factValuePromise;
+ const fact = this._getFact(factId);
+ if (fact === undefined) {
+ if (this.allowUndefinedFacts) {
+ return Promise.resolve(undefined);
+ } else {
+ return Promise.reject(
+ new UndefinedFactError(`Undefined fact: ${factId}`),
+ );
+ }
+ }
+ if (fact.isConstant()) {
+ factValuePromise = Promise.resolve(fact.calculate(params, this));
+ } else {
+ const cacheKey = fact.getCacheKey(params);
+ const cacheVal = cacheKey && this.factResultsCache.get(cacheKey);
+ if (cacheVal) {
+ factValuePromise = Promise.resolve(cacheVal);
+ debug("almanac::factValue cache hit for fact", { id: factId });
+ } else {
+ debug("almanac::factValue cache miss, calculating", { id: factId });
+ factValuePromise = this._setFactValue(
+ fact,
+ params,
+ fact.calculate(params, this),
+ );
+ }
+ }
+ if (path) {
+ debug("condition::evaluate extracting object", { property: path });
+ return factValuePromise.then((factValue) => {
+ if (factValue != null && typeof factValue === "object") {
+ const pathValue = this.pathResolver(factValue, path);
+ debug("condition::evaluate extracting object", {
+ property: path,
+ received: pathValue,
+ });
+ return pathValue;
+ } else {
+ debug(
+ "condition::evaluate could not compute object path of non-object",
+ { path, factValue, type: typeof factValue },
+ );
+ return factValue;
+ }
+ });
+ }
+
+ return factValuePromise;
+ }
+
+ /**
+ * Interprets value as either a primitive, or if a fact, retrieves the fact value
+ */
+ getValue(value) {
+ if (
+ value != null &&
+ typeof value === "object" &&
+ Object.prototype.hasOwnProperty.call(value, "fact")
+ ) {
+ // value = { fact: 'xyz' }
+ return this.factValue(value.fact, value.params, value.path);
+ }
+ return Promise.resolve(value);
+ }
+}
diff --git a/src/condition.js b/src/condition.js
deleted file mode 100644
index 28a86a36..00000000
--- a/src/condition.js
+++ /dev/null
@@ -1,159 +0,0 @@
-'use strict'
-
-import debug from './debug'
-
-export default class Condition {
- constructor (properties) {
- if (!properties) throw new Error('Condition: constructor options required')
- const booleanOperator = Condition.booleanOperator(properties)
- Object.assign(this, properties)
- if (booleanOperator) {
- const subConditions = properties[booleanOperator]
- const subConditionsIsArray = Array.isArray(subConditions)
- if (booleanOperator !== 'not' && !subConditionsIsArray) { throw new Error(`"${booleanOperator}" must be an array`) }
- if (booleanOperator === 'not' && subConditionsIsArray) { throw new Error(`"${booleanOperator}" cannot be an array`) }
- this.operator = booleanOperator
- // boolean conditions always have a priority; default 1
- this.priority = parseInt(properties.priority, 10) || 1
- if (subConditionsIsArray) {
- this[booleanOperator] = subConditions.map((c) => new Condition(c))
- } else {
- this[booleanOperator] = new Condition(subConditions)
- }
- } else if (!Object.prototype.hasOwnProperty.call(properties, 'condition')) {
- if (!Object.prototype.hasOwnProperty.call(properties, 'fact')) { throw new Error('Condition: constructor "fact" property required') }
- if (!Object.prototype.hasOwnProperty.call(properties, 'operator')) { throw new Error('Condition: constructor "operator" property required') }
- if (!Object.prototype.hasOwnProperty.call(properties, 'value')) { throw new Error('Condition: constructor "value" property required') }
-
- // a non-boolean condition does not have a priority by default. this allows
- // priority to be dictated by the fact definition
- if (Object.prototype.hasOwnProperty.call(properties, 'priority')) {
- properties.priority = parseInt(properties.priority, 10)
- }
- }
- }
-
- /**
- * Converts the condition into a json-friendly structure
- * @param {Boolean} stringify - whether to return as a json string
- * @returns {string,object} json string or json-friendly object
- */
- toJSON (stringify = true) {
- const props = {}
- if (this.priority) {
- props.priority = this.priority
- }
- if (this.name) {
- props.name = this.name
- }
- const oper = Condition.booleanOperator(this)
- if (oper) {
- if (Array.isArray(this[oper])) {
- props[oper] = this[oper].map((c) => c.toJSON(false))
- } else {
- props[oper] = this[oper].toJSON(false)
- }
- } else if (this.isConditionReference()) {
- props.condition = this.condition
- } else {
- props.operator = this.operator
- props.value = this.value
- props.fact = this.fact
- if (this.factResult !== undefined) {
- props.factResult = this.factResult
- }
- if (this.result !== undefined) {
- props.result = this.result
- }
- if (this.params) {
- props.params = this.params
- }
- if (this.path) {
- props.path = this.path
- }
- }
- if (stringify) {
- return JSON.stringify(props)
- }
- return props
- }
-
- /**
- * Takes the fact result and compares it to the condition 'value', using the operator
- * LHS OPER RHS
- *
- *
- * @param {Almanac} almanac
- * @param {Map} operatorMap - map of available operators, keyed by operator name
- * @returns {Boolean} - evaluation result
- */
- evaluate (almanac, operatorMap) {
- if (!almanac) return Promise.reject(new Error('almanac required'))
- if (!operatorMap) return Promise.reject(new Error('operatorMap required'))
- if (this.isBooleanOperator()) { return Promise.reject(new Error('Cannot evaluate() a boolean condition')) }
-
- const op = operatorMap.get(this.operator)
- if (!op) { return Promise.reject(new Error(`Unknown operator: ${this.operator}`)) }
-
- return Promise.all([
- almanac.getValue(this.value),
- almanac.factValue(this.fact, this.params, this.path)
- ]).then(([rightHandSideValue, leftHandSideValue]) => {
- const result = op.evaluate(leftHandSideValue, rightHandSideValue)
- debug(
- 'condition::evaluate', {
- leftHandSideValue,
- operator: this.operator,
- rightHandSideValue,
- result
- }
- )
- return {
- result,
- leftHandSideValue,
- rightHandSideValue,
- operator: this.operator
- }
- })
- }
-
- /**
- * Returns the boolean operator for the condition
- * If the condition is not a boolean condition, the result will be 'undefined'
- * @return {string 'all', 'any', or 'not'}
- */
- static booleanOperator (condition) {
- if (Object.prototype.hasOwnProperty.call(condition, 'any')) {
- return 'any'
- } else if (Object.prototype.hasOwnProperty.call(condition, 'all')) {
- return 'all'
- } else if (Object.prototype.hasOwnProperty.call(condition, 'not')) {
- return 'not'
- }
- }
-
- /**
- * Returns the condition's boolean operator
- * Instance version of Condition.isBooleanOperator
- * @returns {string,undefined} - 'any', 'all', 'not' or undefined (if not a boolean condition)
- */
- booleanOperator () {
- return Condition.booleanOperator(this)
- }
-
- /**
- * Whether the operator is boolean ('all', 'any', 'not')
- * @returns {Boolean}
- */
- isBooleanOperator () {
- return Condition.booleanOperator(this) !== undefined
- }
-
- /**
- * Whether the condition represents a reference to a condition
- * @returns {Boolean}
- */
- isConditionReference () {
- return Object.prototype.hasOwnProperty.call(this, 'condition')
- }
-}
diff --git a/src/condition.mjs b/src/condition.mjs
new file mode 100644
index 00000000..4f0ca65d
--- /dev/null
+++ b/src/condition.mjs
@@ -0,0 +1,169 @@
+import debug from "./debug.mjs";
+
+export default class Condition {
+ constructor(properties) {
+ if (!properties) throw new Error("Condition: constructor options required");
+ const booleanOperator = Condition.booleanOperator(properties);
+ Object.assign(this, properties);
+ if (booleanOperator) {
+ const subConditions = properties[booleanOperator];
+ const subConditionsIsArray = Array.isArray(subConditions);
+ if (booleanOperator !== "not" && !subConditionsIsArray) {
+ throw new Error(`"${booleanOperator}" must be an array`);
+ }
+ if (booleanOperator === "not" && subConditionsIsArray) {
+ throw new Error(`"${booleanOperator}" cannot be an array`);
+ }
+ this.operator = booleanOperator;
+ // boolean conditions always have a priority; default 1
+ this.priority = parseInt(properties.priority, 10) || 1;
+ if (subConditionsIsArray) {
+ this[booleanOperator] = subConditions.map((c) => new Condition(c));
+ } else {
+ this[booleanOperator] = new Condition(subConditions);
+ }
+ } else if (!Object.prototype.hasOwnProperty.call(properties, "condition")) {
+ if (!Object.prototype.hasOwnProperty.call(properties, "fact")) {
+ throw new Error('Condition: constructor "fact" property required');
+ }
+ if (!Object.prototype.hasOwnProperty.call(properties, "operator")) {
+ throw new Error('Condition: constructor "operator" property required');
+ }
+ if (!Object.prototype.hasOwnProperty.call(properties, "value")) {
+ throw new Error('Condition: constructor "value" property required');
+ }
+
+ // a non-boolean condition does not have a priority by default. this allows
+ // priority to be dictated by the fact definition
+ if (Object.prototype.hasOwnProperty.call(properties, "priority")) {
+ properties.priority = parseInt(properties.priority, 10);
+ }
+ }
+ }
+
+ /**
+ * Converts the condition into a json-friendly structure
+ * @param {Boolean} stringify - whether to return as a json string
+ * @returns {string,object} json string or json-friendly object
+ */
+ toJSON(stringify = true) {
+ const props = {};
+ if (this.priority) {
+ props.priority = this.priority;
+ }
+ if (this.name) {
+ props.name = this.name;
+ }
+ const oper = Condition.booleanOperator(this);
+ if (oper) {
+ if (Array.isArray(this[oper])) {
+ props[oper] = this[oper].map((c) => c.toJSON(false));
+ } else {
+ props[oper] = this[oper].toJSON(false);
+ }
+ } else if (this.isConditionReference()) {
+ props.condition = this.condition;
+ } else {
+ props.operator = this.operator;
+ props.value = this.value;
+ props.fact = this.fact;
+ if (this.factResult !== undefined) {
+ props.factResult = this.factResult;
+ }
+ if (this.result !== undefined) {
+ props.result = this.result;
+ }
+ if (this.params) {
+ props.params = this.params;
+ }
+ if (this.path) {
+ props.path = this.path;
+ }
+ }
+ if (stringify) {
+ return JSON.stringify(props);
+ }
+ return props;
+ }
+
+ /**
+ * Takes the fact result and compares it to the condition 'value', using the operator
+ * LHS OPER RHS
+ *
+ *
+ * @param {Almanac} almanac
+ * @param {Map} operatorMap - map of available operators, keyed by operator name
+ * @returns {Boolean} - evaluation result
+ */
+ evaluate(almanac, operatorMap) {
+ if (!almanac) return Promise.reject(new Error("almanac required"));
+ if (!operatorMap) return Promise.reject(new Error("operatorMap required"));
+ if (this.isBooleanOperator()) {
+ return Promise.reject(new Error("Cannot evaluate() a boolean condition"));
+ }
+
+ const op = operatorMap.get(this.operator);
+ if (!op) {
+ return Promise.reject(new Error(`Unknown operator: ${this.operator}`));
+ }
+
+ return Promise.all([
+ almanac.getValue(this.value),
+ almanac.factValue(this.fact, this.params, this.path),
+ ]).then(([rightHandSideValue, leftHandSideValue]) => {
+ const result = op.evaluate(leftHandSideValue, rightHandSideValue);
+ debug("condition::evaluate", {
+ leftHandSideValue,
+ operator: this.operator,
+ rightHandSideValue,
+ result,
+ });
+ return {
+ result,
+ leftHandSideValue,
+ rightHandSideValue,
+ operator: this.operator,
+ };
+ });
+ }
+
+ /**
+ * Returns the boolean operator for the condition
+ * If the condition is not a boolean condition, the result will be 'undefined'
+ * @return {string 'all', 'any', or 'not'}
+ */
+ static booleanOperator(condition) {
+ if (Object.prototype.hasOwnProperty.call(condition, "any")) {
+ return "any";
+ } else if (Object.prototype.hasOwnProperty.call(condition, "all")) {
+ return "all";
+ } else if (Object.prototype.hasOwnProperty.call(condition, "not")) {
+ return "not";
+ }
+ }
+
+ /**
+ * Returns the condition's boolean operator
+ * Instance version of Condition.isBooleanOperator
+ * @returns {string,undefined} - 'any', 'all', 'not' or undefined (if not a boolean condition)
+ */
+ booleanOperator() {
+ return Condition.booleanOperator(this);
+ }
+
+ /**
+ * Whether the operator is boolean ('all', 'any', 'not')
+ * @returns {Boolean}
+ */
+ isBooleanOperator() {
+ return Condition.booleanOperator(this) !== undefined;
+ }
+
+ /**
+ * Whether the condition represents a reference to a condition
+ * @returns {Boolean}
+ */
+ isConditionReference() {
+ return Object.prototype.hasOwnProperty.call(this, "condition");
+ }
+}
diff --git a/src/debug.js b/src/debug.js
deleted file mode 100644
index d8744ca9..00000000
--- a/src/debug.js
+++ /dev/null
@@ -1,14 +0,0 @@
-
-function createDebug () {
- try {
- if ((typeof process !== 'undefined' && process.env && process.env.DEBUG && process.env.DEBUG.match(/json-rules-engine/)) ||
- (typeof window !== 'undefined' && window.localStorage && window.localStorage.debug && window.localStorage.debug.match(/json-rules-engine/))) {
- return console.debug.bind(console)
- }
- } catch (ex) {
- // Do nothing
- }
- return () => {}
-}
-
-export default createDebug()
diff --git a/src/debug.mjs b/src/debug.mjs
new file mode 100644
index 00000000..5acfeaa3
--- /dev/null
+++ b/src/debug.mjs
@@ -0,0 +1,21 @@
+function createDebug() {
+ try {
+ if (
+ (typeof process !== "undefined" &&
+ process.env &&
+ process.env.DEBUG &&
+ process.env.DEBUG.match(/json-rules-engine/)) ||
+ (typeof window !== "undefined" &&
+ window.localStorage &&
+ window.localStorage.debug &&
+ window.localStorage.debug.match(/json-rules-engine/))
+ ) {
+ return console.debug.bind(console);
+ }
+ } catch (_error) {
+ // Do nothing
+ }
+ return () => {};
+}
+
+export default createDebug();
diff --git a/src/engine-default-operator-decorators.js b/src/engine-default-operator-decorators.js
deleted file mode 100644
index 4bf83312..00000000
--- a/src/engine-default-operator-decorators.js
+++ /dev/null
@@ -1,14 +0,0 @@
-'use strict'
-
-import OperatorDecorator from './operator-decorator'
-
-const OperatorDecorators = []
-
-OperatorDecorators.push(new OperatorDecorator('someFact', (factValue, jsonValue, next) => factValue.some(fv => next(fv, jsonValue)), Array.isArray))
-OperatorDecorators.push(new OperatorDecorator('someValue', (factValue, jsonValue, next) => jsonValue.some(jv => next(factValue, jv))))
-OperatorDecorators.push(new OperatorDecorator('everyFact', (factValue, jsonValue, next) => factValue.every(fv => next(fv, jsonValue)), Array.isArray))
-OperatorDecorators.push(new OperatorDecorator('everyValue', (factValue, jsonValue, next) => jsonValue.every(jv => next(factValue, jv))))
-OperatorDecorators.push(new OperatorDecorator('swap', (factValue, jsonValue, next) => next(jsonValue, factValue)))
-OperatorDecorators.push(new OperatorDecorator('not', (factValue, jsonValue, next) => !next(factValue, jsonValue)))
-
-export default OperatorDecorators
diff --git a/src/engine-default-operator-decorators.mjs b/src/engine-default-operator-decorators.mjs
new file mode 100644
index 00000000..6959c4dd
--- /dev/null
+++ b/src/engine-default-operator-decorators.mjs
@@ -0,0 +1,42 @@
+import OperatorDecorator from "./operator-decorator.mjs";
+
+const OperatorDecorators = [];
+
+OperatorDecorators.push(
+ new OperatorDecorator(
+ "someFact",
+ (factValue, jsonValue, next) => factValue.some((fv) => next(fv, jsonValue)),
+ Array.isArray,
+ ),
+);
+OperatorDecorators.push(
+ new OperatorDecorator("someValue", (factValue, jsonValue, next) =>
+ jsonValue.some((jv) => next(factValue, jv)),
+ ),
+);
+OperatorDecorators.push(
+ new OperatorDecorator(
+ "everyFact",
+ (factValue, jsonValue, next) =>
+ factValue.every((fv) => next(fv, jsonValue)),
+ Array.isArray,
+ ),
+);
+OperatorDecorators.push(
+ new OperatorDecorator("everyValue", (factValue, jsonValue, next) =>
+ jsonValue.every((jv) => next(factValue, jv)),
+ ),
+);
+OperatorDecorators.push(
+ new OperatorDecorator("swap", (factValue, jsonValue, next) =>
+ next(jsonValue, factValue),
+ ),
+);
+OperatorDecorators.push(
+ new OperatorDecorator(
+ "not",
+ (factValue, jsonValue, next) => !next(factValue, jsonValue),
+ ),
+);
+
+export default OperatorDecorators;
diff --git a/src/engine-default-operators.js b/src/engine-default-operators.js
deleted file mode 100644
index dfe33f29..00000000
--- a/src/engine-default-operators.js
+++ /dev/null
@@ -1,22 +0,0 @@
-'use strict'
-
-import Operator from './operator'
-
-const Operators = []
-Operators.push(new Operator('equal', (a, b) => a === b))
-Operators.push(new Operator('notEqual', (a, b) => a !== b))
-Operators.push(new Operator('in', (a, b) => b.indexOf(a) > -1))
-Operators.push(new Operator('notIn', (a, b) => b.indexOf(a) === -1))
-
-Operators.push(new Operator('contains', (a, b) => a.indexOf(b) > -1, Array.isArray))
-Operators.push(new Operator('doesNotContain', (a, b) => a.indexOf(b) === -1, Array.isArray))
-
-function numberValidator (factValue) {
- return Number.parseFloat(factValue).toString() !== 'NaN'
-}
-Operators.push(new Operator('lessThan', (a, b) => a < b, numberValidator))
-Operators.push(new Operator('lessThanInclusive', (a, b) => a <= b, numberValidator))
-Operators.push(new Operator('greaterThan', (a, b) => a > b, numberValidator))
-Operators.push(new Operator('greaterThanInclusive', (a, b) => a >= b, numberValidator))
-
-export default Operators
diff --git a/src/engine-default-operators.mjs b/src/engine-default-operators.mjs
new file mode 100644
index 00000000..77872ee1
--- /dev/null
+++ b/src/engine-default-operators.mjs
@@ -0,0 +1,28 @@
+import Operator from "./operator.mjs";
+
+const Operators = [];
+Operators.push(new Operator("equal", (a, b) => a === b));
+Operators.push(new Operator("notEqual", (a, b) => a !== b));
+Operators.push(new Operator("in", (a, b) => b.indexOf(a) > -1));
+Operators.push(new Operator("notIn", (a, b) => b.indexOf(a) === -1));
+
+Operators.push(
+ new Operator("contains", (a, b) => a.indexOf(b) > -1, Array.isArray),
+);
+Operators.push(
+ new Operator("doesNotContain", (a, b) => a.indexOf(b) === -1, Array.isArray),
+);
+
+function numberValidator(factValue) {
+ return Number.parseFloat(factValue).toString() !== "NaN";
+}
+Operators.push(new Operator("lessThan", (a, b) => a < b, numberValidator));
+Operators.push(
+ new Operator("lessThanInclusive", (a, b) => a <= b, numberValidator),
+);
+Operators.push(new Operator("greaterThan", (a, b) => a > b, numberValidator));
+Operators.push(
+ new Operator("greaterThanInclusive", (a, b) => a >= b, numberValidator),
+);
+
+export default Operators;
diff --git a/src/engine.js b/src/engine.js
deleted file mode 100644
index 4cb8751e..00000000
--- a/src/engine.js
+++ /dev/null
@@ -1,324 +0,0 @@
-'use strict'
-
-import Fact from './fact'
-import Rule from './rule'
-import Almanac from './almanac'
-import EventEmitter from 'eventemitter2'
-import defaultOperators from './engine-default-operators'
-import defaultDecorators from './engine-default-operator-decorators'
-import debug from './debug'
-import Condition from './condition'
-import OperatorMap from './operator-map'
-
-export const READY = 'READY'
-export const RUNNING = 'RUNNING'
-export const FINISHED = 'FINISHED'
-
-class Engine extends EventEmitter {
- /**
- * Returns a new Engine instance
- * @param {Rule[]} rules - array of rules to initialize with
- */
- constructor (rules = [], options = {}) {
- super()
- this.rules = []
- this.allowUndefinedFacts = options.allowUndefinedFacts || false
- this.allowUndefinedConditions = options.allowUndefinedConditions || false
- this.replaceFactsInEventParams = options.replaceFactsInEventParams || false
- this.pathResolver = options.pathResolver
- this.operators = new OperatorMap()
- this.facts = new Map()
- this.conditions = new Map()
- this.status = READY
- rules.map(r => this.addRule(r))
- defaultOperators.map(o => this.addOperator(o))
- defaultDecorators.map(d => this.addOperatorDecorator(d))
- }
-
- /**
- * Add a rule definition to the engine
- * @param {object|Rule} properties - rule definition. can be JSON representation, or instance of Rule
- * @param {integer} properties.priority (>1) - higher runs sooner.
- * @param {Object} properties.event - event to fire when rule evaluates as successful
- * @param {string} properties.event.type - name of event to emit
- * @param {string} properties.event.params - parameters to pass to the event listener
- * @param {Object} properties.conditions - conditions to evaluate when processing this rule
- */
- addRule (properties) {
- if (!properties) throw new Error('Engine: addRule() requires options')
-
- let rule
- if (properties instanceof Rule) {
- rule = properties
- } else {
- if (!Object.prototype.hasOwnProperty.call(properties, 'event')) throw new Error('Engine: addRule() argument requires "event" property')
- if (!Object.prototype.hasOwnProperty.call(properties, 'conditions')) throw new Error('Engine: addRule() argument requires "conditions" property')
- rule = new Rule(properties)
- }
- rule.setEngine(this)
- this.rules.push(rule)
- this.prioritizedRules = null
- return this
- }
-
- /**
- * update a rule in the engine
- * @param {object|Rule} rule - rule definition. Must be a instance of Rule
- */
- updateRule (rule) {
- const ruleIndex = this.rules.findIndex(ruleInEngine => ruleInEngine.name === rule.name)
- if (ruleIndex > -1) {
- this.rules.splice(ruleIndex, 1)
- this.addRule(rule)
- this.prioritizedRules = null
- } else {
- throw new Error('Engine: updateRule() rule not found')
- }
- }
-
- /**
- * Remove a rule from the engine
- * @param {object|Rule|string} rule - rule definition. Must be a instance of Rule
- */
- removeRule (rule) {
- let ruleRemoved = false
- if (!(rule instanceof Rule)) {
- const filteredRules = this.rules.filter(ruleInEngine => ruleInEngine.name !== rule)
- ruleRemoved = filteredRules.length !== this.rules.length
- this.rules = filteredRules
- } else {
- const index = this.rules.indexOf(rule)
- if (index > -1) {
- ruleRemoved = Boolean(this.rules.splice(index, 1).length)
- }
- }
- if (ruleRemoved) {
- this.prioritizedRules = null
- }
- return ruleRemoved
- }
-
- /**
- * sets a condition that can be referenced by the given name.
- * If a condition with the given name has already been set this will replace it.
- * @param {string} name - the name of the condition to be referenced by rules.
- * @param {object} conditions - the conditions to use when the condition is referenced.
- */
- setCondition (name, conditions) {
- if (!name) throw new Error('Engine: setCondition() requires name')
- if (!conditions) throw new Error('Engine: setCondition() requires conditions')
- if (!Object.prototype.hasOwnProperty.call(conditions, 'all') && !Object.prototype.hasOwnProperty.call(conditions, 'any') && !Object.prototype.hasOwnProperty.call(conditions, 'not') && !Object.prototype.hasOwnProperty.call(conditions, 'condition')) {
- throw new Error('"conditions" root must contain a single instance of "all", "any", "not", or "condition"')
- }
- this.conditions.set(name, new Condition(conditions))
- return this
- }
-
- /**
- * Removes a condition that has previously been added to this engine
- * @param {string} name - the name of the condition to remove.
- * @returns true if the condition existed, otherwise false
- */
- removeCondition (name) {
- return this.conditions.delete(name)
- }
-
- /**
- * Add a custom operator definition
- * @param {string} operatorOrName - operator identifier within the condition; i.e. instead of 'equals', 'greaterThan', etc
- * @param {function(factValue, jsonValue)} callback - the method to execute when the operator is encountered.
- */
- addOperator (operatorOrName, cb) {
- this.operators.addOperator(operatorOrName, cb)
- }
-
- /**
- * Remove a custom operator definition
- * @param {string} operatorOrName - operator identifier within the condition; i.e. instead of 'equals', 'greaterThan', etc
- */
- removeOperator (operatorOrName) {
- return this.operators.removeOperator(operatorOrName)
- }
-
- /**
- * Add a custom operator decorator
- * @param {string} decoratorOrName - decorator identifier within the condition; i.e. instead of 'someFact', 'everyValue', etc
- * @param {function(factValue, jsonValue, next)} callback - the method to execute when the decorator is encountered.
- */
- addOperatorDecorator (decoratorOrName, cb) {
- this.operators.addOperatorDecorator(decoratorOrName, cb)
- }
-
- /**
- * Remove a custom operator decorator
- * @param {string} decoratorOrName - decorator identifier within the condition; i.e. instead of 'someFact', 'everyValue', etc
- */
- removeOperatorDecorator (decoratorOrName) {
- return this.operators.removeOperatorDecorator(decoratorOrName)
- }
-
- /**
- * Add a fact definition to the engine. Facts are called by rules as they are evaluated.
- * @param {object|Fact} id - fact identifier or instance of Fact
- * @param {function} definitionFunc - function to be called when computing the fact value for a given rule
- * @param {Object} options - options to initialize the fact with. used when "id" is not a Fact instance
- */
- addFact (id, valueOrMethod, options) {
- let factId = id
- let fact
- if (id instanceof Fact) {
- factId = id.id
- fact = id
- } else {
- fact = new Fact(id, valueOrMethod, options)
- }
- debug('engine::addFact', { id: factId })
- this.facts.set(factId, fact)
- return this
- }
-
- /**
- * Remove a fact definition to the engine. Facts are called by rules as they are evaluated.
- * @param {object|Fact} id - fact identifier or instance of Fact
- */
- removeFact (factOrId) {
- let factId
- if (!(factOrId instanceof Fact)) {
- factId = factOrId
- } else {
- factId = factOrId.id
- }
-
- return this.facts.delete(factId)
- }
-
- /**
- * Iterates over the engine rules, organizing them by highest -> lowest priority
- * @return {Rule[][]} two dimensional array of Rules.
- * Each outer array element represents a single priority(integer). Inner array is
- * all rules with that priority.
- */
- prioritizeRules () {
- if (!this.prioritizedRules) {
- const ruleSets = this.rules.reduce((sets, rule) => {
- const priority = rule.priority
- if (!sets[priority]) sets[priority] = []
- sets[priority].push(rule)
- return sets
- }, {})
- this.prioritizedRules = Object.keys(ruleSets).sort((a, b) => {
- return Number(a) > Number(b) ? -1 : 1 // order highest priority -> lowest
- }).map((priority) => ruleSets[priority])
- }
- return this.prioritizedRules
- }
-
- /**
- * Stops the rules engine from running the next priority set of Rules. All remaining rules will be resolved as undefined,
- * and no further events emitted. Since rules of the same priority are evaluated in parallel(not series), other rules of
- * the same priority may still emit events, even though the engine is in a "finished" state.
- * @return {Engine}
- */
- stop () {
- this.status = FINISHED
- return this
- }
-
- /**
- * Returns a fact by fact-id
- * @param {string} factId - fact identifier
- * @return {Fact} fact instance, or undefined if no such fact exists
- */
- getFact (factId) {
- return this.facts.get(factId)
- }
-
- /**
- * Runs an array of rules
- * @param {Rule[]} array of rules to be evaluated
- * @return {Promise} resolves when all rules in the array have been evaluated
- */
- evaluateRules (ruleArray, almanac) {
- return Promise.all(ruleArray.map((rule) => {
- if (this.status !== RUNNING) {
- debug('engine::run, skipping remaining rules', { status: this.status })
- return Promise.resolve()
- }
- return rule.evaluate(almanac).then((ruleResult) => {
- debug('engine::run', { ruleResult: ruleResult.result })
- almanac.addResult(ruleResult)
- if (ruleResult.result) {
- almanac.addEvent(ruleResult.event, 'success')
- return this.emitAsync('success', ruleResult.event, almanac, ruleResult)
- .then(() => this.emitAsync(ruleResult.event.type, ruleResult.event.params, almanac, ruleResult))
- } else {
- almanac.addEvent(ruleResult.event, 'failure')
- return this.emitAsync('failure', ruleResult.event, almanac, ruleResult)
- }
- })
- }))
- }
-
- /**
- * Runs the rules engine
- * @param {Object} runtimeFacts - fact values known at runtime
- * @param {Object} runOptions - run options
- * @return {Promise} resolves when the engine has completed running
- */
- run (runtimeFacts = {}, runOptions = {}) {
- debug('engine::run started')
- this.status = RUNNING
-
- const almanac = runOptions.almanac || new Almanac({
- allowUndefinedFacts: this.allowUndefinedFacts,
- pathResolver: this.pathResolver
- })
-
- this.facts.forEach(fact => {
- almanac.addFact(fact)
- })
- for (const factId in runtimeFacts) {
- let fact
- if (runtimeFacts[factId] instanceof Fact) {
- fact = runtimeFacts[factId]
- } else {
- fact = new Fact(factId, runtimeFacts[factId])
- }
-
- almanac.addFact(fact)
- debug('engine::run initialized runtime fact', { id: fact.id, value: fact.value, type: typeof fact.value })
- }
- const orderedSets = this.prioritizeRules()
- let cursor = Promise.resolve()
- // for each rule set, evaluate in parallel,
- // before proceeding to the next priority set.
- return new Promise((resolve, reject) => {
- orderedSets.map((set) => {
- cursor = cursor.then(() => {
- return this.evaluateRules(set, almanac)
- }).catch(reject)
- return cursor
- })
- cursor.then(() => {
- this.status = FINISHED
- debug('engine::run completed')
- const ruleResults = almanac.getResults()
- const { results, failureResults } = ruleResults.reduce((hash, ruleResult) => {
- const group = ruleResult.result ? 'results' : 'failureResults'
- hash[group].push(ruleResult)
- return hash
- }, { results: [], failureResults: [] })
-
- resolve({
- almanac,
- results,
- failureResults,
- events: almanac.getEvents('success'),
- failureEvents: almanac.getEvents('failure')
- })
- }).catch(reject)
- })
- }
-}
-
-export default Engine
diff --git a/src/engine.mjs b/src/engine.mjs
new file mode 100644
index 00000000..86b1dcd5
--- /dev/null
+++ b/src/engine.mjs
@@ -0,0 +1,373 @@
+import Fact from "./fact.mjs";
+import Rule from "./rule.mjs";
+import Almanac from "./almanac.mjs";
+import EventEmitter from "eventemitter2";
+import defaultOperators from "./engine-default-operators.mjs";
+import defaultDecorators from "./engine-default-operator-decorators.mjs";
+import debug from "./debug.mjs";
+import Condition from "./condition.mjs";
+import OperatorMap from "./operator-map.mjs";
+
+export const READY = "READY";
+export const RUNNING = "RUNNING";
+export const FINISHED = "FINISHED";
+
+class Engine extends EventEmitter {
+ /**
+ * Returns a new Engine instance
+ * @param {Rule[]} rules - array of rules to initialize with
+ */
+ constructor(rules = [], options = {}) {
+ super();
+ this.rules = [];
+ this.allowUndefinedFacts = options.allowUndefinedFacts || false;
+ this.allowUndefinedConditions = options.allowUndefinedConditions || false;
+ this.replaceFactsInEventParams = options.replaceFactsInEventParams || false;
+ this.pathResolver = options.pathResolver;
+ this.operators = new OperatorMap();
+ this.facts = new Map();
+ this.conditions = new Map();
+ this.status = READY;
+ rules.map((r) => this.addRule(r));
+ defaultOperators.map((o) => this.addOperator(o));
+ defaultDecorators.map((d) => this.addOperatorDecorator(d));
+ }
+
+ /**
+ * Add a rule definition to the engine
+ * @param {object|Rule} properties - rule definition. can be JSON representation, or instance of Rule
+ * @param {integer} properties.priority (>1) - higher runs sooner.
+ * @param {Object} properties.event - event to fire when rule evaluates as successful
+ * @param {string} properties.event.type - name of event to emit
+ * @param {string} properties.event.params - parameters to pass to the event listener
+ * @param {Object} properties.conditions - conditions to evaluate when processing this rule
+ */
+ addRule(properties) {
+ if (!properties) throw new Error("Engine: addRule() requires options");
+
+ let rule;
+ if (properties instanceof Rule) {
+ rule = properties;
+ } else {
+ if (!Object.prototype.hasOwnProperty.call(properties, "event"))
+ throw new Error('Engine: addRule() argument requires "event" property');
+ if (!Object.prototype.hasOwnProperty.call(properties, "conditions"))
+ throw new Error(
+ 'Engine: addRule() argument requires "conditions" property',
+ );
+ rule = new Rule(properties);
+ }
+ rule.setEngine(this);
+ this.rules.push(rule);
+ this.prioritizedRules = null;
+ return this;
+ }
+
+ /**
+ * update a rule in the engine
+ * @param {object|Rule} rule - rule definition. Must be a instance of Rule
+ */
+ updateRule(rule) {
+ const ruleIndex = this.rules.findIndex(
+ (ruleInEngine) => ruleInEngine.name === rule.name,
+ );
+ if (ruleIndex > -1) {
+ this.rules.splice(ruleIndex, 1);
+ this.addRule(rule);
+ this.prioritizedRules = null;
+ } else {
+ throw new Error("Engine: updateRule() rule not found");
+ }
+ }
+
+ /**
+ * Remove a rule from the engine
+ * @param {object|Rule|string} rule - rule definition. Must be a instance of Rule
+ */
+ removeRule(rule) {
+ let ruleRemoved = false;
+ if (!(rule instanceof Rule)) {
+ const filteredRules = this.rules.filter(
+ (ruleInEngine) => ruleInEngine.name !== rule,
+ );
+ ruleRemoved = filteredRules.length !== this.rules.length;
+ this.rules = filteredRules;
+ } else {
+ const index = this.rules.indexOf(rule);
+ if (index > -1) {
+ ruleRemoved = Boolean(this.rules.splice(index, 1).length);
+ }
+ }
+ if (ruleRemoved) {
+ this.prioritizedRules = null;
+ }
+ return ruleRemoved;
+ }
+
+ /**
+ * sets a condition that can be referenced by the given name.
+ * If a condition with the given name has already been set this will replace it.
+ * @param {string} name - the name of the condition to be referenced by rules.
+ * @param {object} conditions - the conditions to use when the condition is referenced.
+ */
+ setCondition(name, conditions) {
+ if (!name) throw new Error("Engine: setCondition() requires name");
+ if (!conditions)
+ throw new Error("Engine: setCondition() requires conditions");
+ if (
+ !Object.prototype.hasOwnProperty.call(conditions, "all") &&
+ !Object.prototype.hasOwnProperty.call(conditions, "any") &&
+ !Object.prototype.hasOwnProperty.call(conditions, "not") &&
+ !Object.prototype.hasOwnProperty.call(conditions, "condition")
+ ) {
+ throw new Error(
+ '"conditions" root must contain a single instance of "all", "any", "not", or "condition"',
+ );
+ }
+ this.conditions.set(name, new Condition(conditions));
+ return this;
+ }
+
+ /**
+ * Removes a condition that has previously been added to this engine
+ * @param {string} name - the name of the condition to remove.
+ * @returns true if the condition existed, otherwise false
+ */
+ removeCondition(name) {
+ return this.conditions.delete(name);
+ }
+
+ /**
+ * Add a custom operator definition
+ * @param {string} operatorOrName - operator identifier within the condition; i.e. instead of 'equals', 'greaterThan', etc
+ * @param {function(factValue, jsonValue)} callback - the method to execute when the operator is encountered.
+ */
+ addOperator(operatorOrName, cb) {
+ this.operators.addOperator(operatorOrName, cb);
+ }
+
+ /**
+ * Remove a custom operator definition
+ * @param {string} operatorOrName - operator identifier within the condition; i.e. instead of 'equals', 'greaterThan', etc
+ */
+ removeOperator(operatorOrName) {
+ return this.operators.removeOperator(operatorOrName);
+ }
+
+ /**
+ * Add a custom operator decorator
+ * @param {string} decoratorOrName - decorator identifier within the condition; i.e. instead of 'someFact', 'everyValue', etc
+ * @param {function(factValue, jsonValue, next)} callback - the method to execute when the decorator is encountered.
+ */
+ addOperatorDecorator(decoratorOrName, cb) {
+ this.operators.addOperatorDecorator(decoratorOrName, cb);
+ }
+
+ /**
+ * Remove a custom operator decorator
+ * @param {string} decoratorOrName - decorator identifier within the condition; i.e. instead of 'someFact', 'everyValue', etc
+ */
+ removeOperatorDecorator(decoratorOrName) {
+ return this.operators.removeOperatorDecorator(decoratorOrName);
+ }
+
+ /**
+ * Add a fact definition to the engine. Facts are called by rules as they are evaluated.
+ * @param {object|Fact} id - fact identifier or instance of Fact
+ * @param {function} definitionFunc - function to be called when computing the fact value for a given rule
+ * @param {Object} options - options to initialize the fact with. used when "id" is not a Fact instance
+ */
+ addFact(id, valueOrMethod, options) {
+ let factId = id;
+ let fact;
+ if (id instanceof Fact) {
+ factId = id.id;
+ fact = id;
+ } else {
+ fact = new Fact(id, valueOrMethod, options);
+ }
+ debug("engine::addFact", { id: factId });
+ this.facts.set(factId, fact);
+ return this;
+ }
+
+ /**
+ * Remove a fact definition to the engine. Facts are called by rules as they are evaluated.
+ * @param {object|Fact} id - fact identifier or instance of Fact
+ */
+ removeFact(factOrId) {
+ let factId;
+ if (!(factOrId instanceof Fact)) {
+ factId = factOrId;
+ } else {
+ factId = factOrId.id;
+ }
+
+ return this.facts.delete(factId);
+ }
+
+ /**
+ * Iterates over the engine rules, organizing them by highest -> lowest priority
+ * @return {Rule[][]} two dimensional array of Rules.
+ * Each outer array element represents a single priority(integer). Inner array is
+ * all rules with that priority.
+ */
+ prioritizeRules() {
+ if (!this.prioritizedRules) {
+ const ruleSets = this.rules.reduce((sets, rule) => {
+ const priority = rule.priority;
+ if (!sets[priority]) sets[priority] = [];
+ sets[priority].push(rule);
+ return sets;
+ }, {});
+ this.prioritizedRules = Object.keys(ruleSets)
+ .sort((a, b) => {
+ return Number(a) > Number(b) ? -1 : 1; // order highest priority -> lowest
+ })
+ .map((priority) => ruleSets[priority]);
+ }
+ return this.prioritizedRules;
+ }
+
+ /**
+ * Stops the rules engine from running the next priority set of Rules. All remaining rules will be resolved as undefined,
+ * and no further events emitted. Since rules of the same priority are evaluated in parallel(not series), other rules of
+ * the same priority may still emit events, even though the engine is in a "finished" state.
+ * @return {Engine}
+ */
+ stop() {
+ this.status = FINISHED;
+ return this;
+ }
+
+ /**
+ * Returns a fact by fact-id
+ * @param {string} factId - fact identifier
+ * @return {Fact} fact instance, or undefined if no such fact exists
+ */
+ getFact(factId) {
+ return this.facts.get(factId);
+ }
+
+ /**
+ * Runs an array of rules
+ * @param {Rule[]} array of rules to be evaluated
+ * @return {Promise} resolves when all rules in the array have been evaluated
+ */
+ evaluateRules(ruleArray, almanac) {
+ return Promise.all(
+ ruleArray.map((rule) => {
+ if (this.status !== RUNNING) {
+ debug("engine::run, skipping remaining rules", {
+ status: this.status,
+ });
+ return Promise.resolve();
+ }
+ return rule.evaluate(almanac).then((ruleResult) => {
+ debug("engine::run", { ruleResult: ruleResult.result });
+ almanac.addResult(ruleResult);
+ if (ruleResult.result) {
+ almanac.addEvent(ruleResult.event, "success");
+ return this.emitAsync(
+ "success",
+ ruleResult.event,
+ almanac,
+ ruleResult,
+ ).then(() =>
+ this.emitAsync(
+ ruleResult.event.type,
+ ruleResult.event.params,
+ almanac,
+ ruleResult,
+ ),
+ );
+ } else {
+ almanac.addEvent(ruleResult.event, "failure");
+ return this.emitAsync(
+ "failure",
+ ruleResult.event,
+ almanac,
+ ruleResult,
+ );
+ }
+ });
+ }),
+ );
+ }
+
+ /**
+ * Runs the rules engine
+ * @param {Object} runtimeFacts - fact values known at runtime
+ * @param {Object} runOptions - run options
+ * @return {Promise} resolves when the engine has completed running
+ */
+ run(runtimeFacts = {}, runOptions = {}) {
+ debug("engine::run started");
+ this.status = RUNNING;
+
+ const almanac =
+ runOptions.almanac ||
+ new Almanac({
+ allowUndefinedFacts: this.allowUndefinedFacts,
+ pathResolver: this.pathResolver,
+ });
+
+ this.facts.forEach((fact) => {
+ almanac.addFact(fact);
+ });
+ for (const factId in runtimeFacts) {
+ let fact;
+ if (runtimeFacts[factId] instanceof Fact) {
+ fact = runtimeFacts[factId];
+ } else {
+ fact = new Fact(factId, runtimeFacts[factId]);
+ }
+
+ almanac.addFact(fact);
+ debug("engine::run initialized runtime fact", {
+ id: fact.id,
+ value: fact.value,
+ type: typeof fact.value,
+ });
+ }
+ const orderedSets = this.prioritizeRules();
+ let cursor = Promise.resolve();
+ // for each rule set, evaluate in parallel,
+ // before proceeding to the next priority set.
+ return new Promise((resolve, reject) => {
+ orderedSets.map((set) => {
+ cursor = cursor
+ .then(() => {
+ return this.evaluateRules(set, almanac);
+ })
+ .catch(reject);
+ return cursor;
+ });
+ cursor
+ .then(() => {
+ this.status = FINISHED;
+ debug("engine::run completed");
+ const ruleResults = almanac.getResults();
+ const { results, failureResults } = ruleResults.reduce(
+ (hash, ruleResult) => {
+ const group = ruleResult.result ? "results" : "failureResults";
+ hash[group].push(ruleResult);
+ return hash;
+ },
+ { results: [], failureResults: [] },
+ );
+
+ resolve({
+ almanac,
+ results,
+ failureResults,
+ events: almanac.getEvents("success"),
+ failureEvents: almanac.getEvents("failure"),
+ });
+ })
+ .catch(reject);
+ });
+ }
+}
+
+export default Engine;
diff --git a/src/errors.js b/src/errors.js
deleted file mode 100644
index 5f44df36..00000000
--- a/src/errors.js
+++ /dev/null
@@ -1,8 +0,0 @@
-'use strict'
-
-export class UndefinedFactError extends Error {
- constructor (...props) {
- super(...props)
- this.code = 'UNDEFINED_FACT'
- }
-}
diff --git a/src/errors.mjs b/src/errors.mjs
new file mode 100644
index 00000000..6a149906
--- /dev/null
+++ b/src/errors.mjs
@@ -0,0 +1,6 @@
+export class UndefinedFactError extends Error {
+ constructor(...props) {
+ super(...props);
+ this.code = "UNDEFINED_FACT";
+ }
+}
diff --git a/src/fact.js b/src/fact.mjs
similarity index 55%
rename from src/fact.js
rename to src/fact.mjs
index 313ba3d8..2a8cf19d 100644
--- a/src/fact.js
+++ b/src/fact.mjs
@@ -1,6 +1,4 @@
-'use strict'
-
-import hash from 'hash-it'
+import hash from "hash-it";
class Fact {
/**
@@ -11,34 +9,34 @@ class Fact {
* @param {primitive|function} valueOrMethod - constant primitive, or method to call when computing the fact's value
* @return {Fact}
*/
- constructor (id, valueOrMethod, options) {
- this.id = id
- const defaultOptions = { cache: true }
- if (typeof options === 'undefined') {
- options = defaultOptions
+ constructor(id, valueOrMethod, options) {
+ this.id = id;
+ const defaultOptions = { cache: true };
+ if (typeof options === "undefined") {
+ options = defaultOptions;
}
- if (typeof valueOrMethod !== 'function') {
- this.value = valueOrMethod
- this.type = this.constructor.CONSTANT
+ if (typeof valueOrMethod !== "function") {
+ this.value = valueOrMethod;
+ this.type = this.constructor.CONSTANT;
} else {
- this.calculationMethod = valueOrMethod
- this.type = this.constructor.DYNAMIC
+ this.calculationMethod = valueOrMethod;
+ this.type = this.constructor.DYNAMIC;
}
- if (!this.id) throw new Error('factId required')
+ if (!this.id) throw new Error("factId required");
- this.priority = parseInt(options.priority || 1, 10)
- this.options = Object.assign({}, defaultOptions, options)
- this.cacheKeyMethod = this.defaultCacheKeys
- return this
+ this.priority = parseInt(options.priority || 1, 10);
+ this.options = Object.assign({}, defaultOptions, options);
+ this.cacheKeyMethod = this.defaultCacheKeys;
+ return this;
}
- isConstant () {
- return this.type === this.constructor.CONSTANT
+ isConstant() {
+ return this.type === this.constructor.CONSTANT;
}
- isDynamic () {
- return this.type === this.constructor.DYNAMIC
+ isDynamic() {
+ return this.type === this.constructor.DYNAMIC;
}
/**
@@ -47,12 +45,12 @@ class Fact {
* @param {Almanac} almanac
* @return {any} calculation method results
*/
- calculate (params, almanac) {
+ calculate(params, almanac) {
// if constant fact w/set value, return immediately
- if (Object.prototype.hasOwnProperty.call(this, 'value')) {
- return this.value
+ if (Object.prototype.hasOwnProperty.call(this, "value")) {
+ return this.value;
}
- return this.calculationMethod(params, almanac)
+ return this.calculationMethod(params, almanac);
}
/**
@@ -60,8 +58,8 @@ class Fact {
* @param {object} obj - properties to generate a hash key from
* @return {string} MD5 string based on the hash'd object
*/
- static hashFromObject (obj) {
- return hash(obj)
+ static hashFromObject(obj) {
+ return hash(obj);
}
/**
@@ -72,8 +70,8 @@ class Fact {
* @param {object} params - parameters passed to fact calcution method
* @return {object} id + params
*/
- defaultCacheKeys (id, params) {
- return { params, id }
+ defaultCacheKeys(id, params) {
+ return { params, id };
}
/**
@@ -82,16 +80,16 @@ class Fact {
* @param {object} params - parameters that would be passed to the computation method
* @return {string} cache key
*/
- getCacheKey (params) {
+ getCacheKey(params) {
if (this.options.cache === true) {
- const cacheProperties = this.cacheKeyMethod(this.id, params)
- const hash = Fact.hashFromObject(cacheProperties)
- return hash
+ const cacheProperties = this.cacheKeyMethod(this.id, params);
+ const hash = Fact.hashFromObject(cacheProperties);
+ return hash;
}
}
}
-Fact.CONSTANT = 'CONSTANT'
-Fact.DYNAMIC = 'DYNAMIC'
+Fact.CONSTANT = "CONSTANT";
+Fact.DYNAMIC = "DYNAMIC";
-export default Fact
+export default Fact;
diff --git a/src/index.js b/src/index.js
deleted file mode 100644
index 0fdd73f9..00000000
--- a/src/index.js
+++ /dev/null
@@ -1,3 +0,0 @@
-'use strict'
-
-module.exports = require('./json-rules-engine')
diff --git a/src/index.mjs b/src/index.mjs
new file mode 100644
index 00000000..c5eb2bf5
--- /dev/null
+++ b/src/index.mjs
@@ -0,0 +1,11 @@
+import Engine from "./engine.mjs";
+import Fact from "./fact.mjs";
+import Rule from "./rule.mjs";
+import Operator from "./operator.mjs";
+import Almanac from "./almanac.mjs";
+import OperatorDecorator from "./operator-decorator.mjs";
+
+export { Fact, Rule, Operator, Engine, Almanac, OperatorDecorator };
+export default function (rules, options) {
+ return new Engine(rules, options);
+}
diff --git a/src/json-rules-engine.js b/src/json-rules-engine.js
deleted file mode 100644
index bed371d5..00000000
--- a/src/json-rules-engine.js
+++ /dev/null
@@ -1,11 +0,0 @@
-import Engine from './engine'
-import Fact from './fact'
-import Rule from './rule'
-import Operator from './operator'
-import Almanac from './almanac'
-import OperatorDecorator from './operator-decorator'
-
-export { Fact, Rule, Operator, Engine, Almanac, OperatorDecorator }
-export default function (rules, options) {
- return new Engine(rules, options)
-}
diff --git a/src/operator-decorator.js b/src/operator-decorator.mjs
similarity index 54%
rename from src/operator-decorator.js
rename to src/operator-decorator.mjs
index b9196222..b66f8867 100644
--- a/src/operator-decorator.js
+++ b/src/operator-decorator.mjs
@@ -1,6 +1,4 @@
-'use strict'
-
-import Operator from './operator'
+import Operator from "./operator.mjs";
export default class OperatorDecorator {
/**
@@ -10,13 +8,13 @@ export default class OperatorDecorator {
* @param {function} [factValueValidator] - optional validator for asserting the data type of the fact
* @returns {OperatorDecorator} - instance
*/
- constructor (name, cb, factValueValidator) {
- this.name = String(name)
- if (!name) throw new Error('Missing decorator name')
- if (typeof cb !== 'function') throw new Error('Missing decorator callback')
- this.cb = cb
- this.factValueValidator = factValueValidator
- if (!this.factValueValidator) this.factValueValidator = () => true
+ constructor(name, cb, factValueValidator) {
+ this.name = String(name);
+ if (!name) throw new Error("Missing decorator name");
+ if (typeof cb !== "function") throw new Error("Missing decorator callback");
+ this.cb = cb;
+ this.factValueValidator = factValueValidator;
+ if (!this.factValueValidator) this.factValueValidator = () => true;
}
/**
@@ -24,14 +22,14 @@ export default class OperatorDecorator {
* @param {Operator} operator - fact result
* @returns {Operator} - whether the values pass the operator test
*/
- decorate (operator) {
- const next = operator.evaluate.bind(operator)
+ decorate(operator) {
+ const next = operator.evaluate.bind(operator);
return new Operator(
- `${this.name}:${operator.name}`,
- (factValue, jsonValue) => {
- return this.cb(factValue, jsonValue, next)
- },
- this.factValueValidator
- )
+ `${this.name}:${operator.name}`,
+ (factValue, jsonValue) => {
+ return this.cb(factValue, jsonValue, next);
+ },
+ this.factValueValidator,
+ );
}
}
diff --git a/src/operator-map.js b/src/operator-map.js
deleted file mode 100644
index 741e302c..00000000
--- a/src/operator-map.js
+++ /dev/null
@@ -1,137 +0,0 @@
-'use strict'
-
-import Operator from './operator'
-import OperatorDecorator from './operator-decorator'
-import debug from './debug'
-
-export default class OperatorMap {
- constructor () {
- this.operators = new Map()
- this.decorators = new Map()
- }
-
- /**
- * Add a custom operator definition
- * @param {string} operatorOrName - operator identifier within the condition; i.e. instead of 'equals', 'greaterThan', etc
- * @param {function(factValue, jsonValue)} callback - the method to execute when the operator is encountered.
- */
- addOperator (operatorOrName, cb) {
- let operator
- if (operatorOrName instanceof Operator) {
- operator = operatorOrName
- } else {
- operator = new Operator(operatorOrName, cb)
- }
- debug('operatorMap::addOperator', { name: operator.name })
- this.operators.set(operator.name, operator)
- }
-
- /**
- * Remove a custom operator definition
- * @param {string} operatorOrName - operator identifier within the condition; i.e. instead of 'equals', 'greaterThan', etc
- * @param {function(factValue, jsonValue)} callback - the method to execute when the operator is encountered.
- */
- removeOperator (operatorOrName) {
- let operatorName
- if (operatorOrName instanceof Operator) {
- operatorName = operatorOrName.name
- } else {
- operatorName = operatorOrName
- }
-
- // Delete all the operators that end in :operatorName these
- // were decorated on-the-fly leveraging this operator
- const suffix = ':' + operatorName
- const operatorNames = Array.from(this.operators.keys())
- for (let i = 0; i < operatorNames.length; i++) {
- if (operatorNames[i].endsWith(suffix)) {
- this.operators.delete(operatorNames[i])
- }
- }
-
- return this.operators.delete(operatorName)
- }
-
- /**
- * Add a custom operator decorator
- * @param {string} decoratorOrName - decorator identifier within the condition; i.e. instead of 'everyFact', 'someValue', etc
- * @param {function(factValue, jsonValue, next)} callback - the method to execute when the decorator is encountered.
- */
- addOperatorDecorator (decoratorOrName, cb) {
- let decorator
- if (decoratorOrName instanceof OperatorDecorator) {
- decorator = decoratorOrName
- } else {
- decorator = new OperatorDecorator(decoratorOrName, cb)
- }
- debug('operatorMap::addOperatorDecorator', { name: decorator.name })
- this.decorators.set(decorator.name, decorator)
- }
-
- /**
- * Remove a custom operator decorator
- * @param {string} decoratorOrName - decorator identifier within the condition; i.e. instead of 'everyFact', 'someValue', etc
- */
- removeOperatorDecorator (decoratorOrName) {
- let decoratorName
- if (decoratorOrName instanceof OperatorDecorator) {
- decoratorName = decoratorOrName.name
- } else {
- decoratorName = decoratorOrName
- }
-
- // Delete all the operators that include decoratorName: these
- // were decorated on-the-fly leveraging this decorator
- const prefix = decoratorName + ':'
- const operatorNames = Array.from(this.operators.keys())
- for (let i = 0; i < operatorNames.length; i++) {
- if (operatorNames[i].includes(prefix)) {
- this.operators.delete(operatorNames[i])
- }
- }
-
- return this.decorators.delete(decoratorName)
- }
-
- /**
- * Get the Operator, or null applies decorators as needed
- * @param {string} name - the name of the operator including any decorators
- * @returns an operator or null
- */
- get (name) {
- const decorators = []
- let opName = name
- // while we don't already have this operator
- while (!this.operators.has(opName)) {
- // try splitting on the decorator symbol (:)
- const firstDecoratorIndex = opName.indexOf(':')
- if (firstDecoratorIndex > 0) {
- // if there is a decorator, and it's a valid decorator
- const decoratorName = opName.slice(0, firstDecoratorIndex)
- const decorator = this.decorators.get(decoratorName)
- if (!decorator) {
- debug('operatorMap::get invalid decorator', { name: decoratorName })
- return null
- }
- // we're going to apply this later, use unshift since we'll apply in reverse order
- decorators.unshift(decorator)
- // continue looking for a known operator with the rest of the name
- opName = opName.slice(firstDecoratorIndex + 1)
- } else {
- debug('operatorMap::get no operator', { name: opName })
- return null
- }
- }
-
- let op = this.operators.get(opName)
- // apply all the decorators
- for (let i = 0; i < decorators.length; i++) {
- op = decorators[i].decorate(op)
- // create an entry for the decorated operation so we don't need
- // to do this again
- this.operators.set(op.name, op)
- }
- // return the operation
- return op
- }
-}
diff --git a/src/operator-map.mjs b/src/operator-map.mjs
new file mode 100644
index 00000000..9fe55b64
--- /dev/null
+++ b/src/operator-map.mjs
@@ -0,0 +1,135 @@
+import Operator from "./operator.mjs";
+import OperatorDecorator from "./operator-decorator.mjs";
+import debug from "./debug.mjs";
+
+export default class OperatorMap {
+ constructor() {
+ this.operators = new Map();
+ this.decorators = new Map();
+ }
+
+ /**
+ * Add a custom operator definition
+ * @param {string} operatorOrName - operator identifier within the condition; i.e. instead of 'equals', 'greaterThan', etc
+ * @param {function(factValue, jsonValue)} callback - the method to execute when the operator is encountered.
+ */
+ addOperator(operatorOrName, cb) {
+ let operator;
+ if (operatorOrName instanceof Operator) {
+ operator = operatorOrName;
+ } else {
+ operator = new Operator(operatorOrName, cb);
+ }
+ debug("operatorMap::addOperator", { name: operator.name });
+ this.operators.set(operator.name, operator);
+ }
+
+ /**
+ * Remove a custom operator definition
+ * @param {string} operatorOrName - operator identifier within the condition; i.e. instead of 'equals', 'greaterThan', etc
+ * @param {function(factValue, jsonValue)} callback - the method to execute when the operator is encountered.
+ */
+ removeOperator(operatorOrName) {
+ let operatorName;
+ if (operatorOrName instanceof Operator) {
+ operatorName = operatorOrName.name;
+ } else {
+ operatorName = operatorOrName;
+ }
+
+ // Delete all the operators that end in :operatorName these
+ // were decorated on-the-fly leveraging this operator
+ const suffix = ":" + operatorName;
+ const operatorNames = Array.from(this.operators.keys());
+ for (let i = 0; i < operatorNames.length; i++) {
+ if (operatorNames[i].endsWith(suffix)) {
+ this.operators.delete(operatorNames[i]);
+ }
+ }
+
+ return this.operators.delete(operatorName);
+ }
+
+ /**
+ * Add a custom operator decorator
+ * @param {string} decoratorOrName - decorator identifier within the condition; i.e. instead of 'everyFact', 'someValue', etc
+ * @param {function(factValue, jsonValue, next)} callback - the method to execute when the decorator is encountered.
+ */
+ addOperatorDecorator(decoratorOrName, cb) {
+ let decorator;
+ if (decoratorOrName instanceof OperatorDecorator) {
+ decorator = decoratorOrName;
+ } else {
+ decorator = new OperatorDecorator(decoratorOrName, cb);
+ }
+ debug("operatorMap::addOperatorDecorator", { name: decorator.name });
+ this.decorators.set(decorator.name, decorator);
+ }
+
+ /**
+ * Remove a custom operator decorator
+ * @param {string} decoratorOrName - decorator identifier within the condition; i.e. instead of 'everyFact', 'someValue', etc
+ */
+ removeOperatorDecorator(decoratorOrName) {
+ let decoratorName;
+ if (decoratorOrName instanceof OperatorDecorator) {
+ decoratorName = decoratorOrName.name;
+ } else {
+ decoratorName = decoratorOrName;
+ }
+
+ // Delete all the operators that include decoratorName: these
+ // were decorated on-the-fly leveraging this decorator
+ const prefix = decoratorName + ":";
+ const operatorNames = Array.from(this.operators.keys());
+ for (let i = 0; i < operatorNames.length; i++) {
+ if (operatorNames[i].includes(prefix)) {
+ this.operators.delete(operatorNames[i]);
+ }
+ }
+
+ return this.decorators.delete(decoratorName);
+ }
+
+ /**
+ * Get the Operator, or null applies decorators as needed
+ * @param {string} name - the name of the operator including any decorators
+ * @returns an operator or null
+ */
+ get(name) {
+ const decorators = [];
+ let opName = name;
+ // while we don't already have this operator
+ while (!this.operators.has(opName)) {
+ // try splitting on the decorator symbol (:)
+ const firstDecoratorIndex = opName.indexOf(":");
+ if (firstDecoratorIndex > 0) {
+ // if there is a decorator, and it's a valid decorator
+ const decoratorName = opName.slice(0, firstDecoratorIndex);
+ const decorator = this.decorators.get(decoratorName);
+ if (!decorator) {
+ debug("operatorMap::get invalid decorator", { name: decoratorName });
+ return null;
+ }
+ // we're going to apply this later, use unshift since we'll apply in reverse order
+ decorators.unshift(decorator);
+ // continue looking for a known operator with the rest of the name
+ opName = opName.slice(firstDecoratorIndex + 1);
+ } else {
+ debug("operatorMap::get no operator", { name: opName });
+ return null;
+ }
+ }
+
+ let op = this.operators.get(opName);
+ // apply all the decorators
+ for (let i = 0; i < decorators.length; i++) {
+ op = decorators[i].decorate(op);
+ // create an entry for the decorated operation so we don't need
+ // to do this again
+ this.operators.set(op.name, op);
+ }
+ // return the operation
+ return op;
+ }
+}
diff --git a/src/operator.js b/src/operator.mjs
similarity index 68%
rename from src/operator.js
rename to src/operator.mjs
index e342ced5..66664aaa 100644
--- a/src/operator.js
+++ b/src/operator.mjs
@@ -1,5 +1,3 @@
-'use strict'
-
export default class Operator {
/**
* Constructor
@@ -8,13 +6,13 @@ export default class Operator {
* @param {function} [factValueValidator] - optional validator for asserting the data type of the fact
* @returns {Operator} - instance
*/
- constructor (name, cb, factValueValidator) {
- this.name = String(name)
- if (!name) throw new Error('Missing operator name')
- if (typeof cb !== 'function') throw new Error('Missing operator callback')
- this.cb = cb
- this.factValueValidator = factValueValidator
- if (!this.factValueValidator) this.factValueValidator = () => true
+ constructor(name, cb, factValueValidator) {
+ this.name = String(name);
+ if (!name) throw new Error("Missing operator name");
+ if (typeof cb !== "function") throw new Error("Missing operator callback");
+ this.cb = cb;
+ this.factValueValidator = factValueValidator;
+ if (!this.factValueValidator) this.factValueValidator = () => true;
}
/**
@@ -23,7 +21,7 @@ export default class Operator {
* @param {mixed} jsonValue - "value" property of the condition
* @returns {Boolean} - whether the values pass the operator test
*/
- evaluate (factValue, jsonValue) {
- return this.factValueValidator(factValue) && this.cb(factValue, jsonValue)
+ evaluate(factValue, jsonValue) {
+ return this.factValueValidator(factValue) && this.cb(factValue, jsonValue);
}
}
diff --git a/src/rule-result.js b/src/rule-result.js
deleted file mode 100644
index 09350c7e..00000000
--- a/src/rule-result.js
+++ /dev/null
@@ -1,48 +0,0 @@
-'use strict'
-
-import deepClone from 'clone'
-
-export default class RuleResult {
- constructor (conditions, event, priority, name) {
- this.conditions = deepClone(conditions)
- this.event = deepClone(event)
- this.priority = deepClone(priority)
- this.name = deepClone(name)
- this.result = null
- }
-
- setResult (result) {
- this.result = result
- }
-
- resolveEventParams (almanac) {
- if (this.event.params !== null && typeof this.event.params === 'object') {
- const updates = []
- for (const key in this.event.params) {
- if (Object.prototype.hasOwnProperty.call(this.event.params, key)) {
- updates.push(
- almanac
- .getValue(this.event.params[key])
- .then((val) => (this.event.params[key] = val))
- )
- }
- }
- return Promise.all(updates)
- }
- return Promise.resolve()
- }
-
- toJSON (stringify = true) {
- const props = {
- conditions: this.conditions.toJSON(false),
- event: this.event,
- priority: this.priority,
- name: this.name,
- result: this.result
- }
- if (stringify) {
- return JSON.stringify(props)
- }
- return props
- }
-}
diff --git a/src/rule-result.mjs b/src/rule-result.mjs
new file mode 100644
index 00000000..3ee5c83d
--- /dev/null
+++ b/src/rule-result.mjs
@@ -0,0 +1,46 @@
+import deepClone from "clone";
+
+export default class RuleResult {
+ constructor(conditions, event, priority, name) {
+ this.conditions = deepClone(conditions);
+ this.event = deepClone(event);
+ this.priority = deepClone(priority);
+ this.name = deepClone(name);
+ this.result = null;
+ }
+
+ setResult(result) {
+ this.result = result;
+ }
+
+ resolveEventParams(almanac) {
+ if (this.event.params !== null && typeof this.event.params === "object") {
+ const updates = [];
+ for (const key in this.event.params) {
+ if (Object.prototype.hasOwnProperty.call(this.event.params, key)) {
+ updates.push(
+ almanac
+ .getValue(this.event.params[key])
+ .then((val) => (this.event.params[key] = val)),
+ );
+ }
+ }
+ return Promise.all(updates);
+ }
+ return Promise.resolve();
+ }
+
+ toJSON(stringify = true) {
+ const props = {
+ conditions: this.conditions.toJSON(false),
+ event: this.event,
+ priority: this.priority,
+ name: this.name,
+ result: this.result,
+ };
+ if (stringify) {
+ return JSON.stringify(props);
+ }
+ return props;
+ }
+}
diff --git a/src/rule.js b/src/rule.mjs
similarity index 61%
rename from src/rule.js
rename to src/rule.mjs
index 39e5a327..922b5707 100644
--- a/src/rule.js
+++ b/src/rule.mjs
@@ -1,10 +1,8 @@
-'use strict'
-
-import Condition from './condition'
-import RuleResult from './rule-result'
-import debug from './debug'
-import deepClone from 'clone'
-import EventEmitter from 'eventemitter2'
+import Condition from "./condition.mjs";
+import RuleResult from "./rule-result.mjs";
+import debug from "./debug.mjs";
+import deepClone from "clone";
+import EventEmitter from "eventemitter2";
class Rule extends EventEmitter {
/**
@@ -18,71 +16,71 @@ class Rule extends EventEmitter {
* @param {any} options.name - identifier for a particular rule, particularly valuable in RuleResult output
* @return {Rule} instance
*/
- constructor (options) {
- super()
- if (typeof options === 'string') {
- options = JSON.parse(options)
+ constructor(options) {
+ super();
+ if (typeof options === "string") {
+ options = JSON.parse(options);
}
if (options && options.conditions) {
- this.setConditions(options.conditions)
+ this.setConditions(options.conditions);
}
if (options && options.onSuccess) {
- this.on('success', options.onSuccess)
+ this.on("success", options.onSuccess);
}
if (options && options.onFailure) {
- this.on('failure', options.onFailure)
+ this.on("failure", options.onFailure);
}
if (options && (options.name || options.name === 0)) {
- this.setName(options.name)
+ this.setName(options.name);
}
- const priority = (options && options.priority) || 1
- this.setPriority(priority)
+ const priority = (options && options.priority) || 1;
+ this.setPriority(priority);
- const event = (options && options.event) || { type: 'unknown' }
- this.setEvent(event)
+ const event = (options && options.event) || { type: "unknown" };
+ this.setEvent(event);
}
/**
* Sets the priority of the rule
* @param {integer} priority (>=1) - increasing the priority causes the rule to be run prior to other rules
*/
- setPriority (priority) {
- priority = parseInt(priority, 10)
- if (priority <= 0) throw new Error('Priority must be greater than zero')
- this.priority = priority
- return this
+ setPriority(priority) {
+ priority = parseInt(priority, 10);
+ if (priority <= 0) throw new Error("Priority must be greater than zero");
+ this.priority = priority;
+ return this;
}
/**
* Sets the name of the rule
* @param {any} name - any truthy input and zero is allowed
*/
- setName (name) {
+ setName(name) {
if (!name && name !== 0) {
- throw new Error('Rule "name" must be defined')
+ throw new Error('Rule "name" must be defined');
}
- this.name = name
- return this
+ this.name = name;
+ return this;
}
/**
* Sets the conditions to run when evaluating the rule.
* @param {object} conditions - conditions, root element must be a boolean operator
*/
- setConditions (conditions) {
+ setConditions(conditions) {
if (
- !Object.prototype.hasOwnProperty.call(conditions, 'all') &&
- !Object.prototype.hasOwnProperty.call(conditions, 'any') &&
- !Object.prototype.hasOwnProperty.call(conditions, 'not') &&
- !Object.prototype.hasOwnProperty.call(conditions, 'condition')
+ !Object.prototype.hasOwnProperty.call(conditions, "all") &&
+ !Object.prototype.hasOwnProperty.call(conditions, "any") &&
+ !Object.prototype.hasOwnProperty.call(conditions, "not") &&
+ !Object.prototype.hasOwnProperty.call(conditions, "condition")
) {
throw new Error(
- '"conditions" root must contain a single instance of "all", "any", "not", or "condition"'
- )
+ '"conditions" root must contain a single instance of "all", "any", "not", or "condition"',
+ );
}
- this.conditions = new Condition(conditions)
- return this
+ this.conditions = new Condition(conditions);
+ return this;
}
/**
@@ -91,50 +89,50 @@ class Rule extends EventEmitter {
* @param {string} event.type - event name to emit on
* @param {string} event.params - parameters to emit as the argument of the event emission
*/
- setEvent (event) {
- if (!event) throw new Error('Rule: setEvent() requires event object')
- if (!Object.prototype.hasOwnProperty.call(event, 'type')) {
+ setEvent(event) {
+ if (!event) throw new Error("Rule: setEvent() requires event object");
+ if (!Object.prototype.hasOwnProperty.call(event, "type")) {
throw new Error(
- 'Rule: setEvent() requires event object with "type" property'
- )
+ 'Rule: setEvent() requires event object with "type" property',
+ );
}
this.ruleEvent = {
- type: event.type
- }
- if (event.params) this.ruleEvent.params = event.params
- return this
+ type: event.type,
+ };
+ if (event.params) this.ruleEvent.params = event.params;
+ return this;
}
/**
* returns the event object
* @returns {Object} event
*/
- getEvent () {
- return this.ruleEvent
+ getEvent() {
+ return this.ruleEvent;
}
/**
* returns the priority
* @returns {Number} priority
*/
- getPriority () {
- return this.priority
+ getPriority() {
+ return this.priority;
}
/**
* returns the event object
* @returns {Object} event
*/
- getConditions () {
- return this.conditions
+ getConditions() {
+ return this.conditions;
}
/**
* returns the engine object
* @returns {Object} engine
*/
- getEngine () {
- return this.engine
+ getEngine() {
+ return this.engine;
}
/**
@@ -142,22 +140,22 @@ class Rule extends EventEmitter {
* @param {object} engine
* @returns {Rule}
*/
- setEngine (engine) {
- this.engine = engine
- return this
+ setEngine(engine) {
+ this.engine = engine;
+ return this;
}
- toJSON (stringify = true) {
+ toJSON(stringify = true) {
const props = {
conditions: this.conditions.toJSON(false),
priority: this.priority,
event: this.ruleEvent,
- name: this.name
- }
+ name: this.name,
+ };
if (stringify) {
- return JSON.stringify(props)
+ return JSON.stringify(props);
}
- return props
+ return props;
}
/**
@@ -168,24 +166,24 @@ class Rule extends EventEmitter {
* Each outer array element represents a single priority(integer). Inner array is
* all conditions with that priority.
*/
- prioritizeConditions (conditions) {
+ prioritizeConditions(conditions) {
const factSets = conditions.reduce((sets, condition) => {
// if a priority has been set on this specific condition, honor that first
// otherwise, use the fact's priority
- let priority = condition.priority
+ let priority = condition.priority;
if (!priority) {
- const fact = this.engine.getFact(condition.fact)
- priority = (fact && fact.priority) || 1
+ const fact = this.engine.getFact(condition.fact);
+ priority = (fact && fact.priority) || 1;
}
- if (!sets[priority]) sets[priority] = []
- sets[priority].push(condition)
- return sets
- }, {})
+ if (!sets[priority]) sets[priority] = [];
+ sets[priority].push(condition);
+ return sets;
+ }, {});
return Object.keys(factSets)
.sort((a, b) => {
- return Number(a) > Number(b) ? -1 : 1 // order highest priority -> lowest
+ return Number(a) > Number(b) ? -1 : 1; // order highest priority -> lowest
})
- .map((priority) => factSets[priority])
+ .map((priority) => factSets[priority]);
}
/**
@@ -193,13 +191,13 @@ class Rule extends EventEmitter {
* All evaluation is done within the context of an almanac
* @return {Promise(RuleResult)} rule evaluation result
*/
- evaluate (almanac) {
+ evaluate(almanac) {
const ruleResult = new RuleResult(
this.conditions,
this.ruleEvent,
this.priority,
- this.name
- )
+ this.name,
+ );
/**
* Evaluates the rule conditions
@@ -208,34 +206,34 @@ class Rule extends EventEmitter {
*/
const evaluateCondition = (condition) => {
if (condition.isConditionReference()) {
- return realize(condition)
+ return realize(condition);
} else if (condition.isBooleanOperator()) {
- const subConditions = condition[condition.operator]
- let comparisonPromise
- if (condition.operator === 'all') {
- comparisonPromise = all(subConditions)
- } else if (condition.operator === 'any') {
- comparisonPromise = any(subConditions)
+ const subConditions = condition[condition.operator];
+ let comparisonPromise;
+ if (condition.operator === "all") {
+ comparisonPromise = all(subConditions);
+ } else if (condition.operator === "any") {
+ comparisonPromise = any(subConditions);
} else {
- comparisonPromise = not(subConditions)
+ comparisonPromise = not(subConditions);
}
// for booleans, rule passing is determined by the all/any/not result
return comparisonPromise.then((comparisonValue) => {
- const passes = comparisonValue === true
- condition.result = passes
- return passes
- })
+ const passes = comparisonValue === true;
+ condition.result = passes;
+ return passes;
+ });
} else {
return condition
.evaluate(almanac, this.engine.operators)
.then((evaluationResult) => {
- const passes = evaluationResult.result
- condition.factResult = evaluationResult.leftHandSideValue
- condition.result = passes
- return passes
- })
+ const passes = evaluationResult.result;
+ condition.factResult = evaluationResult.leftHandSideValue;
+ condition.result = passes;
+ return passes;
+ });
}
- }
+ };
/**
* Evalutes an array of conditions, using an 'every' or 'some' array operation
@@ -244,15 +242,15 @@ class Rule extends EventEmitter {
* @return {Promise(boolean)} whether conditions evaluated truthy or falsey based on condition evaluation + method
*/
const evaluateConditions = (conditions, method) => {
- if (!Array.isArray(conditions)) conditions = [conditions]
+ if (!Array.isArray(conditions)) conditions = [conditions];
return Promise.all(
- conditions.map((condition) => evaluateCondition(condition))
+ conditions.map((condition) => evaluateCondition(condition)),
).then((conditionResults) => {
- debug('rule::evaluateConditions', { results: conditionResults })
- return method.call(conditionResults, (result) => result === true)
- })
- }
+ debug("rule::evaluateConditions", { results: conditionResults });
+ return method.call(conditionResults, (result) => result === true);
+ });
+ };
/**
* Evaluates a set of conditions based on an 'all', 'any', or 'not' operator.
@@ -266,28 +264,28 @@ class Rule extends EventEmitter {
*/
const prioritizeAndRun = (conditions, operator) => {
if (conditions.length === 0) {
- return Promise.resolve(true)
+ return Promise.resolve(true);
}
if (conditions.length === 1) {
// no prioritizing is necessary, just evaluate the single condition
// 'all' and 'any' will give the same results with a single condition so no method is necessary
// this also covers the 'not' case which should only ever have a single condition
- return evaluateCondition(conditions[0])
+ return evaluateCondition(conditions[0]);
}
- const orderedSets = this.prioritizeConditions(conditions)
- let cursor = Promise.resolve(operator === 'all')
+ const orderedSets = this.prioritizeConditions(conditions);
+ let cursor = Promise.resolve(operator === "all");
// use for() loop over Array.forEach to support IE8 without polyfill
for (let i = 0; i < orderedSets.length; i++) {
- const set = orderedSets[i]
+ const set = orderedSets[i];
cursor = cursor.then((setResult) => {
// rely on the short-circuiting behavior of || and && to avoid evaluating subsequent conditions
- return operator === 'any'
- ? (setResult || evaluateConditions(set, Array.prototype.some))
- : (setResult && evaluateConditions(set, Array.prototype.every))
- })
+ return operator === "any"
+ ? setResult || evaluateConditions(set, Array.prototype.some)
+ : setResult && evaluateConditions(set, Array.prototype.every);
+ });
}
- return cursor
- }
+ return cursor;
+ };
/**
* Runs an 'any' boolean operator on an array of conditions
@@ -295,8 +293,8 @@ class Rule extends EventEmitter {
* @return {Promise(boolean)} condition evaluation result
*/
const any = (conditions) => {
- return prioritizeAndRun(conditions, 'any')
- }
+ return prioritizeAndRun(conditions, "any");
+ };
/**
* Runs an 'all' boolean operator on an array of conditions
@@ -304,8 +302,8 @@ class Rule extends EventEmitter {
* @return {Promise(boolean)} condition evaluation result
*/
const all = (conditions) => {
- return prioritizeAndRun(conditions, 'all')
- }
+ return prioritizeAndRun(conditions, "all");
+ };
/**
* Runs a 'not' boolean operator on a single condition
@@ -313,8 +311,8 @@ class Rule extends EventEmitter {
* @return {Promise(boolean)} condition evaluation result
*/
const not = (condition) => {
- return prioritizeAndRun([condition], 'not').then((result) => !result)
- }
+ return prioritizeAndRun([condition], "not").then((result) => !result);
+ };
/**
* Dereferences the condition reference and then evaluates it.
@@ -322,59 +320,63 @@ class Rule extends EventEmitter {
* @returns {Promise(boolean)} condition evaluation result
*/
const realize = (conditionReference) => {
- const condition = this.engine.conditions.get(conditionReference.condition)
+ const condition = this.engine.conditions.get(
+ conditionReference.condition,
+ );
if (!condition) {
if (this.engine.allowUndefinedConditions) {
// undefined conditions always fail
- conditionReference.result = false
- return Promise.resolve(false)
+ conditionReference.result = false;
+ return Promise.resolve(false);
} else {
throw new Error(
- `No condition ${conditionReference.condition} exists`
- )
+ `No condition ${conditionReference.condition} exists`,
+ );
}
} else {
// project the referenced condition onto reference object and evaluate it.
- delete conditionReference.condition
- Object.assign(conditionReference, deepClone(condition))
- return evaluateCondition(conditionReference)
+ delete conditionReference.condition;
+ Object.assign(conditionReference, deepClone(condition));
+ return evaluateCondition(conditionReference);
}
- }
+ };
/**
* Emits based on rule evaluation result, and decorates ruleResult with 'result' property
* @param {RuleResult} ruleResult
*/
const processResult = (result) => {
- ruleResult.setResult(result)
- let processEvent = Promise.resolve()
+ ruleResult.setResult(result);
+ let processEvent = Promise.resolve();
if (this.engine.replaceFactsInEventParams) {
- processEvent = ruleResult.resolveEventParams(almanac)
+ processEvent = ruleResult.resolveEventParams(almanac);
}
- const event = result ? 'success' : 'failure'
- return processEvent.then(() => this.emitAsync(event, ruleResult.event, almanac, ruleResult)).then(
- () => ruleResult
- )
- }
+ const event = result ? "success" : "failure";
+ return processEvent
+ .then(() =>
+ this.emitAsync(event, ruleResult.event, almanac, ruleResult),
+ )
+ .then(() => ruleResult);
+ };
if (ruleResult.conditions.any) {
return any(ruleResult.conditions.any).then((result) =>
- processResult(result)
- )
+ processResult(result),
+ );
} else if (ruleResult.conditions.all) {
return all(ruleResult.conditions.all).then((result) =>
- processResult(result)
- )
+ processResult(result),
+ );
} else if (ruleResult.conditions.not) {
return not(ruleResult.conditions.not).then((result) =>
- processResult(result)
- )
+ processResult(result),
+ );
} else {
- return realize(
- ruleResult.conditions
- ).then((result) => processResult(result))
+ return realize(ruleResult.conditions).then((result) =>
+ processResult(result),
+ );
}
}
}
-export default Rule
+export default Rule;
diff --git a/test/acceptance/acceptance.js b/test/acceptance/acceptance.js
deleted file mode 100644
index fb68cc77..00000000
--- a/test/acceptance/acceptance.js
+++ /dev/null
@@ -1,268 +0,0 @@
-'use strict'
-
-import sinon from 'sinon'
-import { expect } from 'chai'
-import { Engine } from '../../src/index'
-
-/**
- * acceptance tests are intended to use features that, when used in combination,
- * could cause integration bugs not caught by the rest of the test suite
- */
-describe('Acceptance', () => {
- let sandbox
- before(() => {
- sandbox = sinon.createSandbox()
- })
- afterEach(() => {
- sandbox.restore()
- })
- const factParam = 1
- const event1 = {
- type: 'event-1',
- params: {
- eventParam: 1
- }
- }
- const event2 = {
- type: 'event-2'
- }
- const expectedFirstRuleResult = {
- all: [{
- fact: 'high-priority',
- params: {
- factParam
- },
- operator: 'contains',
- path: '$.values',
- value: 2,
- factResult: [2],
- result: true
- },
- {
- fact: 'low-priority',
- operator: 'in',
- value: [2],
- factResult: 2,
- result: true
- }
- ],
- operator: 'all',
- priority: 1
- }
- let successSpy
- let failureSpy
- let highPrioritySpy
- let lowPrioritySpy
-
- function delay (value) {
- return new Promise(resolve => setTimeout(() => resolve(value), 5))
- }
-
- function setup (options = {}) {
- const engine = new Engine()
- highPrioritySpy = sandbox.spy()
- lowPrioritySpy = sandbox.spy()
-
- engine.addRule({
- name: 'first',
- priority: 10,
- conditions: {
- all: [{
- fact: 'high-priority',
- params: {
- factParam
- },
- operator: 'contains',
- path: '$.values',
- value: options.highPriorityValue
- }, {
- fact: 'low-priority',
- operator: 'in',
- value: options.lowPriorityValue
- }]
- },
- event: event1,
- onSuccess: async (event, almanac, ruleResults) => {
- expect(ruleResults.name).to.equal('first')
- expect(ruleResults.event).to.deep.equal(event1)
- expect(ruleResults.priority).to.equal(10)
- expect(ruleResults.conditions).to.deep.equal(expectedFirstRuleResult)
-
- return delay(almanac.addRuntimeFact('rule-created-fact', { array: options.highPriorityValue }))
- }
- })
-
- engine.addRule({
- name: 'second',
- priority: 1,
- conditions: {
- all: [{
- fact: 'high-priority',
- params: {
- factParam
- },
- operator: 'containsDivisibleValuesOf',
- path: '$.values',
- value: {
- fact: 'rule-created-fact',
- path: '$.array' // set by 'success' of first rule
- }
- }]
- },
- event: event2
- })
-
- engine.addOperator('containsDivisibleValuesOf', (factValue, jsonValue) => {
- return factValue.some(v => v % jsonValue === 0)
- })
-
- engine.addFact('high-priority', async function (params, almanac) {
- highPrioritySpy(params)
- const idx = await almanac.factValue('sub-fact')
- return delay({ values: [idx + params.factParam] }) // { values: [baseIndex + factParam] }
- }, { priority: 2 })
-
- engine.addFact('low-priority', async function (params, almanac) {
- lowPrioritySpy(params)
- const idx = await almanac.factValue('sub-fact')
- return delay(idx + 1) // baseIndex + 1
- }, { priority: 1 })
-
- engine.addFact('sub-fact', async function (params, almanac) {
- const baseIndex = await almanac.factValue('baseIndex')
- return delay(baseIndex)
- })
- successSpy = sandbox.spy()
- failureSpy = sandbox.spy()
- engine.on('success', successSpy)
- engine.on('failure', failureSpy)
-
- return engine
- }
-
- it('succeeds', async () => {
- const engine = setup({
- highPriorityValue: 2,
- lowPriorityValue: [2]
- })
-
- const {
- results,
- failureResults,
- events,
- failureEvents
- } = await engine.run({ baseIndex: 1 })
-
- // results
- expect(results.length).to.equal(2)
- expect(results[0]).to.deep.equal({
- conditions: {
- all: [
- {
- fact: 'high-priority',
- factResult: [
- 2
- ],
- operator: 'contains',
- params: {
- factParam: 1
- },
- path: '$.values',
- result: true,
- value: 2
- },
- {
- fact: 'low-priority',
- factResult: 2,
- operator: 'in',
- result: true,
- value: [
- 2
- ]
- }
- ],
- operator: 'all',
- priority: 1
- },
- event: {
- params: {
- eventParam: 1
- },
- type: 'event-1'
- },
- name: 'first',
- priority: 10,
- result: true
- })
- expect(results[1]).to.deep.equal({
- conditions: {
- all: [
- {
- fact: 'high-priority',
- factResult: [
- 2
- ],
- operator: 'containsDivisibleValuesOf',
- params: {
- factParam: 1
- },
- path: '$.values',
- result: true,
- value: {
- fact: 'rule-created-fact',
- path: '$.array'
- }
- }
- ],
- operator: 'all',
- priority: 1
- },
- event: {
- type: 'event-2'
- },
- name: 'second',
- priority: 1,
- result: true
- })
- expect(failureResults).to.be.empty()
-
- // events
- expect(failureEvents.length).to.equal(0)
- expect(events.length).to.equal(2)
- expect(events[0]).to.deep.equal(event1)
- expect(events[1]).to.deep.equal(event2)
-
- // callbacks
- expect(successSpy).to.have.been.calledTwice()
- expect(successSpy).to.have.been.calledWith(event1)
- expect(successSpy).to.have.been.calledWith(event2)
- expect(highPrioritySpy).to.have.been.calledBefore(lowPrioritySpy)
- expect(failureSpy).to.not.have.been.called()
- })
-
- it('fails', async () => {
- const engine = setup({
- highPriorityValue: 2,
- lowPriorityValue: [3] // falsey
- })
-
- const {
- results,
- failureResults,
- events,
- failureEvents
- } = await engine.run({ baseIndex: 1, 'rule-created-fact': '' })
-
- expect(results.length).to.equal(0)
- expect(failureResults.length).to.equal(2)
- expect(failureResults.every(rr => rr.result === false)).to.be.true()
-
- expect(events.length).to.equal(0)
- expect(failureEvents.length).to.equal(2)
- expect(failureSpy).to.have.been.calledTwice()
- expect(failureSpy).to.have.been.calledWith(event1)
- expect(failureSpy).to.have.been.calledWith(event2)
- expect(highPrioritySpy).to.have.been.calledBefore(lowPrioritySpy)
- expect(successSpy).to.not.have.been.called()
- })
-})
diff --git a/test/acceptance/acceptance.test.mjs b/test/acceptance/acceptance.test.mjs
new file mode 100644
index 00000000..db703895
--- /dev/null
+++ b/test/acceptance/acceptance.test.mjs
@@ -0,0 +1,283 @@
+import { Engine } from "../../src/index.mjs";
+import { describe, it, expect, vi } from "vitest";
+/**
+ * acceptance tests are intended to use features that, when used in combination,
+ * could cause integration bugs not caught by the rest of the test suite
+ */
+describe("Acceptance", () => {
+ const factParam = 1;
+ const event1 = {
+ type: "event-1",
+ params: {
+ eventParam: 1,
+ },
+ };
+ const event2 = {
+ type: "event-2",
+ };
+ const expectedFirstRuleResult = {
+ all: [
+ {
+ fact: "high-priority",
+ params: {
+ factParam,
+ },
+ operator: "contains",
+ path: "$.values",
+ value: 2,
+ factResult: [2],
+ result: true,
+ },
+ {
+ fact: "low-priority",
+ operator: "in",
+ value: [2],
+ factResult: 2,
+ result: true,
+ },
+ ],
+ operator: "all",
+ priority: 1,
+ };
+ let successSpy;
+ let failureSpy;
+ let highPrioritySpy;
+ let lowPrioritySpy;
+
+ function delay(value) {
+ return new Promise((resolve) => setTimeout(() => resolve(value), 5));
+ }
+
+ function setup(options = {}) {
+ const engine = new Engine();
+ highPrioritySpy = vi.fn();
+ lowPrioritySpy = vi.fn();
+
+ engine.addRule({
+ name: "first",
+ priority: 10,
+ conditions: {
+ all: [
+ {
+ fact: "high-priority",
+ params: {
+ factParam,
+ },
+ operator: "contains",
+ path: "$.values",
+ value: options.highPriorityValue,
+ },
+ {
+ fact: "low-priority",
+ operator: "in",
+ value: options.lowPriorityValue,
+ },
+ ],
+ },
+ event: event1,
+ onSuccess: async (event, almanac, ruleResults) => {
+ expect(ruleResults.name).toBe("first");
+ expect(ruleResults.event).toEqual(event1);
+ expect(ruleResults.priority).toBe(10);
+ expect(ruleResults.conditions).toEqual(expectedFirstRuleResult);
+
+ return delay(
+ almanac.addRuntimeFact("rule-created-fact", {
+ array: options.highPriorityValue,
+ }),
+ );
+ },
+ });
+
+ engine.addRule({
+ name: "second",
+ priority: 1,
+ conditions: {
+ all: [
+ {
+ fact: "high-priority",
+ params: {
+ factParam,
+ },
+ operator: "containsDivisibleValuesOf",
+ path: "$.values",
+ value: {
+ fact: "rule-created-fact",
+ path: "$.array", // set by 'success' of first rule
+ },
+ },
+ ],
+ },
+ event: event2,
+ });
+
+ engine.addOperator("containsDivisibleValuesOf", (factValue, jsonValue) => {
+ return factValue.some((v) => v % jsonValue === 0);
+ });
+
+ engine.addFact(
+ "high-priority",
+ async function (params, almanac) {
+ highPrioritySpy(params);
+ const idx = await almanac.factValue("sub-fact");
+ return delay({ values: [idx + params.factParam] }); // { values: [baseIndex + factParam] }
+ },
+ { priority: 2 },
+ );
+
+ engine.addFact(
+ "low-priority",
+ async function (params, almanac) {
+ lowPrioritySpy(params);
+ const idx = await almanac.factValue("sub-fact");
+ return delay(idx + 1); // baseIndex + 1
+ },
+ { priority: 1 },
+ );
+
+ engine.addFact("sub-fact", async function (params, almanac) {
+ const baseIndex = await almanac.factValue("baseIndex");
+ return delay(baseIndex);
+ });
+ successSpy = vi.fn();
+ failureSpy = vi.fn();
+ engine.on("success", successSpy);
+ engine.on("failure", failureSpy);
+
+ return engine;
+ }
+
+ it("succeeds", async () => {
+ const engine = setup({
+ highPriorityValue: 2,
+ lowPriorityValue: [2],
+ });
+
+ const { results, failureResults, events, failureEvents } = await engine.run(
+ { baseIndex: 1 },
+ );
+
+ // results
+ expect(results.length).toBe(2);
+ expect(results[0]).toEqual({
+ conditions: {
+ all: [
+ {
+ fact: "high-priority",
+ factResult: [2],
+ operator: "contains",
+ params: {
+ factParam: 1,
+ },
+ path: "$.values",
+ result: true,
+ value: 2,
+ },
+ {
+ fact: "low-priority",
+ factResult: 2,
+ operator: "in",
+ result: true,
+ value: [2],
+ },
+ ],
+ operator: "all",
+ priority: 1,
+ },
+ event: {
+ params: {
+ eventParam: 1,
+ },
+ type: "event-1",
+ },
+ name: "first",
+ priority: 10,
+ result: true,
+ });
+ expect(results[1]).toEqual({
+ conditions: {
+ all: [
+ {
+ fact: "high-priority",
+ factResult: [2],
+ operator: "containsDivisibleValuesOf",
+ params: {
+ factParam: 1,
+ },
+ path: "$.values",
+ result: true,
+ value: {
+ fact: "rule-created-fact",
+ path: "$.array",
+ },
+ },
+ ],
+ operator: "all",
+ priority: 1,
+ },
+ event: {
+ type: "event-2",
+ },
+ name: "second",
+ priority: 1,
+ result: true,
+ });
+ expect(failureResults).toHaveLength(0);
+
+ // events
+ expect(failureEvents.length).toBe(0);
+ expect(events.length).toBe(2);
+ expect(events[0]).toEqual(event1);
+ expect(events[1]).toEqual(event2);
+
+ // callbacks
+ expect(successSpy).toHaveBeenCalledTimes(2);
+ expect(successSpy).toHaveBeenCalledWith(
+ event1,
+ expect.anything(),
+ expect.anything(),
+ );
+ expect(successSpy).toHaveBeenCalledWith(
+ event2,
+ expect.anything(),
+ expect.anything(),
+ );
+ expect(Math.min(...highPrioritySpy.mock.invocationCallOrder)).toBeLessThan(
+ Math.min(...lowPrioritySpy.mock.invocationCallOrder),
+ );
+ expect(failureSpy).not.toHaveBeenCalled();
+ });
+
+ it("fails", async () => {
+ const engine = setup({
+ highPriorityValue: 2,
+ lowPriorityValue: [3], // falsey
+ });
+
+ const { results, failureResults, events, failureEvents } = await engine.run(
+ { baseIndex: 1, "rule-created-fact": "" },
+ );
+
+ expect(results.length).toBe(0);
+ expect(failureResults.length).toBe(2);
+ expect(failureResults.every((rr) => rr.result === false)).toBe(true);
+
+ expect(events.length).toBe(0);
+ expect(failureEvents.length).toBe(2);
+ expect(failureSpy).toHaveBeenCalledTimes(2);
+ expect(failureSpy).toHaveBeenCalledWith(
+ event1,
+ expect.anything(),
+ expect.anything(),
+ );
+ expect(failureSpy).toHaveBeenCalledWith(
+ event2,
+ expect.anything(),
+ expect.anything(),
+ );
+ expect(Math.min(...highPrioritySpy.mock.invocationCallOrder)).toBeLessThan(
+ Math.min(...lowPrioritySpy.mock.invocationCallOrder),
+ );
+ expect(successSpy).not.toHaveBeenCalled();
+ });
+});
diff --git a/test/almanac.test.js b/test/almanac.test.js
deleted file mode 100644
index b9701839..00000000
--- a/test/almanac.test.js
+++ /dev/null
@@ -1,200 +0,0 @@
-import { Fact } from '../src/index'
-import Almanac from '../src/almanac'
-import sinon from 'sinon'
-
-describe('Almanac', () => {
- let almanac
- let factSpy
- let sandbox
- before(() => {
- sandbox = sinon.createSandbox()
- })
- beforeEach(() => {
- factSpy = sandbox.spy()
- })
- afterEach(() => {
- sandbox.restore()
- })
-
- describe('properties', () => {
- it('has methods for managing facts', () => {
- almanac = new Almanac()
- expect(almanac).to.have.property('factValue')
- })
-
- it('adds runtime facts', () => {
- almanac = new Almanac()
- almanac.addFact('modelId', 'XYZ')
- expect(almanac.factMap.get('modelId').value).to.equal('XYZ')
- })
- })
-
- describe('addFact', () => {
- it('supports runtime facts as key => values', () => {
- almanac = new Almanac()
- almanac.addFact('fact1', 3)
- return expect(almanac.factValue('fact1')).to.eventually.equal(3)
- })
-
- it('supporrts runtime facts as dynamic callbacks', async () => {
- almanac = new Almanac()
- almanac.addFact('fact1', () => {
- factSpy()
- return Promise.resolve(3)
- })
- await expect(almanac.factValue('fact1')).to.eventually.equal(3)
- await expect(factSpy).to.have.been.calledOnce()
- })
-
- it('supports runtime fact instances', () => {
- const fact = new Fact('fact1', 3)
- almanac = new Almanac()
- almanac.addFact(fact)
- return expect(almanac.factValue('fact1')).to.eventually.equal(fact.value)
- })
- })
-
- describe('addEvent() / getEvents()', () => {
- const event = {};
- ['success', 'failure'].forEach(outcome => {
- it(`manages ${outcome} events`, () => {
- almanac = new Almanac()
- expect(almanac.getEvents(outcome)).to.be.empty()
- almanac.addEvent(event, outcome)
- expect(almanac.getEvents(outcome)).to.have.a.lengthOf(1)
- expect(almanac.getEvents(outcome)[0]).to.equal(event)
- })
-
- it('getEvent() filters when outcome provided, or returns all events', () => {
- almanac = new Almanac()
- almanac.addEvent(event, 'success')
- almanac.addEvent(event, 'failure')
- expect(almanac.getEvents('success')).to.have.a.lengthOf(1)
- expect(almanac.getEvents('failure')).to.have.a.lengthOf(1)
- expect(almanac.getEvents()).to.have.a.lengthOf(2)
- })
- })
- })
-
- describe('arguments', () => {
- beforeEach(() => {
- const fact = new Fact('foo', async (params, facts) => {
- if (params.userId) return params.userId
- return 'unknown'
- })
- almanac = new Almanac()
- almanac.addFact(fact)
- })
-
- it('allows parameters to be passed to the fact', async () => {
- return expect(almanac.factValue('foo')).to.eventually.equal('unknown')
- })
-
- it('allows parameters to be passed to the fact', async () => {
- return expect(almanac.factValue('foo', { userId: 1 })).to.eventually.equal(1)
- })
-
- it('throws an exception if it encounters an undefined fact', () => {
- return expect(almanac.factValue('bar')).to.be.rejectedWith(/Undefined fact: bar/)
- })
- })
-
- describe('addRuntimeFact', () => {
- it('adds a key/value pair to the factMap as a fact instance', () => {
- almanac = new Almanac()
- almanac.addRuntimeFact('factId', 'factValue')
- expect(almanac.factMap.get('factId').value).to.equal('factValue')
- })
- })
-
- describe('_addConstantFact', () => {
- it('adds fact instances to the factMap', () => {
- const fact = new Fact('factId', 'factValue')
- almanac = new Almanac()
- almanac._addConstantFact(fact)
- expect(almanac.factMap.get(fact.id).value).to.equal(fact.value)
- })
- })
-
- describe('_getFact', _ => {
- it('retrieves the fact object', () => {
- const fact = new Fact('id', 1)
- almanac = new Almanac()
- almanac.addFact(fact)
- expect(almanac._getFact('id')).to.equal(fact)
- })
- })
-
- describe('_setFactValue()', () => {
- function expectFactResultsCache (expected) {
- const promise = almanac.factResultsCache.values().next().value
- expect(promise).to.be.instanceof(Promise)
- promise.then(value => expect(value).to.equal(expected))
- return promise
- }
-
- function setup (f = new Fact('id', 1)) {
- fact = f
- almanac = new Almanac()
- almanac.addFact(fact)
- }
- let fact
- const FACT_VALUE = 2
-
- it('updates the fact results and returns a promise', (done) => {
- setup()
- almanac._setFactValue(fact, {}, FACT_VALUE)
- expectFactResultsCache(FACT_VALUE).then(_ => done()).catch(done)
- })
-
- it('honors facts with caching disabled', (done) => {
- setup(new Fact('id', 1, { cache: false }))
- const promise = almanac._setFactValue(fact, {}, FACT_VALUE)
- expect(almanac.factResultsCache.values().next().value).to.be.undefined()
- promise.then(value => expect(value).to.equal(FACT_VALUE)).then(_ => done()).catch(done)
- })
- })
-
- describe('factValue()', () => {
- it('allows "path" to be specified to traverse the fact data with json-path', async () => {
- const fact = new Fact('foo', {
- users: [{
- name: 'George'
- }, {
- name: 'Thomas'
- }]
- })
- almanac = new Almanac()
- almanac.addFact(fact)
- const result = await almanac.factValue('foo', null, '$..name')
- expect(result).to.deep.equal(['George', 'Thomas'])
- })
-
- describe('caching', () => {
- function setup (factOptions) {
- const fact = new Fact('foo', async (params, facts) => {
- factSpy()
- return 'unknown'
- }, factOptions)
- almanac = new Almanac()
- almanac.addFact(fact)
- }
-
- it('evaluates the fact every time when fact caching is off', () => {
- setup({ cache: false })
- almanac.factValue('foo')
- almanac.factValue('foo')
- almanac.factValue('foo')
- expect(factSpy).to.have.been.calledThrice()
- })
-
- it('evaluates the fact once when fact caching is on', () => {
- setup({ cache: true })
- almanac.factValue('foo')
- almanac.factValue('foo')
- almanac.factValue('foo')
- expect(factSpy).to.have.been.calledOnce()
- })
- })
- })
-})
diff --git a/test/almanac.test.mjs b/test/almanac.test.mjs
new file mode 100644
index 00000000..6a64cff9
--- /dev/null
+++ b/test/almanac.test.mjs
@@ -0,0 +1,203 @@
+import { Fact } from "../src/index.mjs";
+import Almanac from "../src/almanac.mjs";
+import { describe, it, beforeEach, expect, vi } from "vitest";
+
+describe("Almanac", () => {
+ let almanac;
+ let factSpy;
+
+ beforeEach(() => {
+ factSpy = vi.fn();
+ });
+
+ describe("properties", () => {
+ it("has methods for managing facts", () => {
+ almanac = new Almanac();
+ expect(almanac).toHaveProperty("factValue");
+ });
+
+ it("adds runtime facts", () => {
+ almanac = new Almanac();
+ almanac.addFact("modelId", "XYZ");
+ expect(almanac.factMap.get("modelId").value).toBe("XYZ");
+ });
+ });
+
+ describe("addFact", () => {
+ it("supports runtime facts as key => values", () => {
+ almanac = new Almanac();
+ almanac.addFact("fact1", 3);
+ return expect(almanac.factValue("fact1")).resolves.toBe(3);
+ });
+
+ it("supporrts runtime facts as dynamic callbacks", async () => {
+ almanac = new Almanac();
+ almanac.addFact("fact1", () => {
+ factSpy();
+ return Promise.resolve(3);
+ });
+ await expect(almanac.factValue("fact1")).resolves.toBe(3);
+ await expect(factSpy).toHaveBeenCalledOnce();
+ });
+
+ it("supports runtime fact instances", () => {
+ const fact = new Fact("fact1", 3);
+ almanac = new Almanac();
+ almanac.addFact(fact);
+ return expect(almanac.factValue("fact1")).resolves.toBe(fact.value);
+ });
+ });
+
+ describe("addEvent() / getEvents()", () => {
+ const event = {};
+ ["success", "failure"].forEach((outcome) => {
+ it(`manages ${outcome} events`, () => {
+ almanac = new Almanac();
+ expect(almanac.getEvents(outcome)).toHaveLength(0);
+ almanac.addEvent(event, outcome);
+ expect(almanac.getEvents(outcome)).toHaveLength(1);
+ expect(almanac.getEvents(outcome)[0]).toEqual(event);
+ });
+
+ it("getEvent() filters when outcome provided, or returns all events", () => {
+ almanac = new Almanac();
+ almanac.addEvent(event, "success");
+ almanac.addEvent(event, "failure");
+ expect(almanac.getEvents("success")).toHaveLength(1);
+ expect(almanac.getEvents("failure")).toHaveLength(1);
+ expect(almanac.getEvents()).toHaveLength(2);
+ });
+ });
+ });
+
+ describe("arguments", () => {
+ beforeEach(() => {
+ const fact = new Fact("foo", async (params) => {
+ if (params.userId) return params.userId;
+ return "unknown";
+ });
+ almanac = new Almanac();
+ almanac.addFact(fact);
+ });
+
+ it("allows parameters to be passed to the fact", async () => {
+ return expect(almanac.factValue("foo")).resolves.toBe("unknown");
+ });
+
+ it("allows parameters to be passed to the fact", async () => {
+ return expect(almanac.factValue("foo", { userId: 1 })).resolves.toBe(1);
+ });
+
+ it("throws an exception if it encounters an undefined fact", () => {
+ return expect(almanac.factValue("bar")).rejects.toThrow(
+ /Undefined fact: bar/,
+ );
+ });
+ });
+
+ describe("addRuntimeFact", () => {
+ it("adds a key/value pair to the factMap as a fact instance", () => {
+ almanac = new Almanac();
+ almanac.addRuntimeFact("factId", "factValue");
+ expect(almanac.factMap.get("factId").value).toBe("factValue");
+ });
+ });
+
+ describe("_addConstantFact", () => {
+ it("adds fact instances to the factMap", () => {
+ const fact = new Fact("factId", "factValue");
+ almanac = new Almanac();
+ almanac._addConstantFact(fact);
+ expect(almanac.factMap.get(fact.id).value).toBe(fact.value);
+ });
+ });
+
+ describe("_getFact", () => {
+ it("retrieves the fact object", () => {
+ const fact = new Fact("id", 1);
+ almanac = new Almanac();
+ almanac.addFact(fact);
+ expect(almanac._getFact("id")).toEqual(fact);
+ });
+ });
+
+ describe("_setFactValue()", () => {
+ function expectFactResultsCache(expected) {
+ const promise = almanac.factResultsCache.values().next().value;
+ expect(promise).toBeInstanceOf(Promise);
+ promise.then((value) => expect(value).toEqual(expected));
+ return promise;
+ }
+
+ function setup(f = new Fact("id", 1)) {
+ fact = f;
+ almanac = new Almanac();
+ almanac.addFact(fact);
+ }
+ let fact;
+ const FACT_VALUE = 2;
+
+ it("updates the fact results and returns a promise", async () => {
+ setup();
+ almanac._setFactValue(fact, {}, FACT_VALUE);
+ await expectFactResultsCache(FACT_VALUE);
+ });
+
+ it("honors facts with caching disabled", async () => {
+ setup(new Fact("id", 1, { cache: false }));
+ const promise = almanac._setFactValue(fact, {}, FACT_VALUE);
+ expect(almanac.factResultsCache.values().next().value).toBeUndefined();
+ await expect(promise).resolves.toBe(FACT_VALUE);
+ });
+ });
+
+ describe("factValue()", () => {
+ it('allows "path" to be specified to traverse the fact data with json-path', async () => {
+ const fact = new Fact("foo", {
+ users: [
+ {
+ name: "George",
+ },
+ {
+ name: "Thomas",
+ },
+ ],
+ });
+ almanac = new Almanac();
+ almanac.addFact(fact);
+ const result = await almanac.factValue("foo", null, "$..name");
+ expect(result).toEqual(["George", "Thomas"]);
+ });
+
+ describe("caching", () => {
+ function setup(factOptions) {
+ const fact = new Fact(
+ "foo",
+ async () => {
+ factSpy();
+ return "unknown";
+ },
+ factOptions,
+ );
+ almanac = new Almanac();
+ almanac.addFact(fact);
+ }
+
+ it("evaluates the fact every time when fact caching is off", () => {
+ setup({ cache: false });
+ almanac.factValue("foo");
+ almanac.factValue("foo");
+ almanac.factValue("foo");
+ expect(factSpy).toHaveBeenCalledTimes(3);
+ });
+
+ it("evaluates the fact once when fact caching is on", () => {
+ setup({ cache: true });
+ almanac.factValue("foo");
+ almanac.factValue("foo");
+ almanac.factValue("foo");
+ expect(factSpy).toHaveBeenCalledOnce();
+ });
+ });
+ });
+});
diff --git a/test/condition.test.js b/test/condition.test.js
deleted file mode 100644
index cd1d8f54..00000000
--- a/test/condition.test.js
+++ /dev/null
@@ -1,380 +0,0 @@
-'use strict'
-
-import Condition from '../src/condition'
-import defaultOperators from '../src/engine-default-operators'
-import Almanac from '../src/almanac'
-import Fact from '../src/fact'
-
-const operators = new Map()
-defaultOperators.forEach(o => operators.set(o.name, o))
-
-function condition () {
- return {
- all: [{
- id: '6ed20017-375f-40c9-a1d2-6d7e0f4733c5',
- name: 'team participation in form',
- fact: 'team_participation',
- operator: 'equal',
- value: 50,
- path: '.metrics[0].forum-posts'
- }]
- }
-}
-
-describe('Condition', () => {
- describe('constructor', () => {
- it('fact conditions have properties', () => {
- const properties = condition()
- const subject = new Condition(properties.all[0])
- expect(subject).to.have.property('fact')
- expect(subject).to.have.property('operator')
- expect(subject).to.have.property('value')
- expect(subject).to.have.property('path')
- expect(subject).to.have.property('name')
- })
-
- it('boolean conditions have properties', () => {
- const properties = condition()
- const subject = new Condition(properties)
- expect(subject).to.have.property('operator')
- expect(subject).to.have.property('priority')
- expect(subject.priority).to.equal(1)
- })
- })
-
- describe('toJSON', () => {
- it('converts the condition into a json string', () => {
- const properties = factories.condition({
- fact: 'age',
- value: {
- fact: 'weight',
- params: {
- unit: 'lbs'
- },
- path: '.value'
- }
- })
- const condition = new Condition(properties)
- const json = condition.toJSON()
- expect(json).to.equal('{"operator":"equal","value":{"fact":"weight","params":{"unit":"lbs"},"path":".value"},"fact":"age"}')
- })
-
- it('converts "not" conditions', () => {
- const properties = {
- not: {
- ...factories.condition({
- fact: 'age',
- value: {
- fact: 'weight',
- params: {
- unit: 'lbs'
- },
- path: '.value'
- }
- })
- }
- }
- const condition = new Condition(properties)
- const json = condition.toJSON()
- expect(json).to.equal('{"priority":1,"not":{"operator":"equal","value":{"fact":"weight","params":{"unit":"lbs"},"path":".value"},"fact":"age"}}')
- })
- })
-
- describe('evaluate', () => {
- const conditionBase = factories.condition({
- fact: 'age',
- value: 50
- })
- let condition
- let almanac
- function setup (options, factValue) {
- const properties = Object.assign({}, conditionBase, options)
- condition = new Condition(properties)
- const fact = new Fact(conditionBase.fact, factValue)
- almanac = new Almanac()
- almanac.addFact(fact)
- }
-
- context('validations', () => {
- beforeEach(() => setup({}, 1))
- it('throws when missing an almanac', () => {
- return expect(condition.evaluate(undefined, operators)).to.be.rejectedWith('almanac required')
- })
- it('throws when missing operators', () => {
- return expect(condition.evaluate(almanac, undefined)).to.be.rejectedWith('operatorMap required')
- })
- it('throws when run against a boolean operator', () => {
- condition.all = []
- return expect(condition.evaluate(almanac, operators)).to.be.rejectedWith('Cannot evaluate() a boolean condition')
- })
- })
-
- it('evaluates "equal"', async () => {
- setup({ operator: 'equal' }, 50)
- expect((await condition.evaluate(almanac, operators, 50)).result).to.equal(true)
- setup({ operator: 'equal' }, 5)
- expect((await condition.evaluate(almanac, operators, 5)).result).to.equal(false)
- })
-
- it('evaluates "equal" to check for undefined', async () => {
- condition = new Condition({ fact: 'age', operator: 'equal', value: undefined })
- let fact = new Fact('age', undefined)
- almanac = new Almanac()
- almanac.addFact(fact)
-
- expect((await condition.evaluate(almanac, operators)).result).to.equal(true)
-
- fact = new Fact('age', 1)
- almanac = new Almanac()
- almanac.addFact(fact)
- expect((await condition.evaluate(almanac, operators)).result).to.equal(false)
- })
-
- it('evaluates "notEqual"', async () => {
- setup({ operator: 'notEqual' }, 50)
- expect((await condition.evaluate(almanac, operators)).result).to.equal(false)
- setup({ operator: 'notEqual' }, 5)
- expect((await condition.evaluate(almanac, operators)).result).to.equal(true)
- })
-
- it('evaluates "in"', async () => {
- setup({ operator: 'in', value: [5, 10, 15, 20] }, 15)
- expect((await condition.evaluate(almanac, operators)).result).to.equal(true)
- setup({ operator: 'in', value: [5, 10, 15, 20] }, 99)
- expect((await condition.evaluate(almanac, operators)).result).to.equal(false)
- })
-
- it('evaluates "contains"', async () => {
- setup({ operator: 'contains', value: 10 }, [5, 10, 15])
- expect((await condition.evaluate(almanac, operators)).result).to.equal(true)
- setup({ operator: 'contains', value: 10 }, [1, 2, 3])
- expect((await condition.evaluate(almanac, operators)).result).to.equal(false)
- })
-
- it('evaluates "doesNotContain"', async () => {
- setup({ operator: 'doesNotContain', value: 10 }, [5, 10, 15])
- expect((await condition.evaluate(almanac, operators)).result).to.equal(false)
- setup({ operator: 'doesNotContain', value: 10 }, [1, 2, 3])
- expect((await condition.evaluate(almanac, operators)).result).to.equal(true)
- })
-
- it('evaluates "notIn"', async () => {
- setup({ operator: 'notIn', value: [5, 10, 15, 20] }, 15)
- expect((await condition.evaluate(almanac, operators)).result).to.equal(false)
- setup({ operator: 'notIn', value: [5, 10, 15, 20] }, 99)
- expect((await condition.evaluate(almanac, operators)).result).to.equal(true)
- })
-
- it('evaluates "lessThan"', async () => {
- setup({ operator: 'lessThan' }, 49)
- expect((await condition.evaluate(almanac, operators)).result).to.equal(true)
- setup({ operator: 'lessThan' }, 50)
- expect((await condition.evaluate(almanac, operators)).result).to.equal(false)
- setup({ operator: 'lessThan' }, 51)
- expect((await condition.evaluate(almanac, operators)).result).to.equal(false)
- })
-
- it('evaluates "lessThanInclusive"', async () => {
- setup({ operator: 'lessThanInclusive' }, 49)
- expect((await condition.evaluate(almanac, operators)).result).to.equal(true)
- setup({ operator: 'lessThanInclusive' }, 50)
- expect((await condition.evaluate(almanac, operators)).result).to.equal(true)
- setup({ operator: 'lessThanInclusive' }, 51)
- expect((await condition.evaluate(almanac, operators)).result).to.equal(false)
- })
-
- it('evaluates "greaterThan"', async () => {
- setup({ operator: 'greaterThan' }, 51)
- expect((await condition.evaluate(almanac, operators)).result).to.equal(true)
- setup({ operator: 'greaterThan' }, 49)
- expect((await condition.evaluate(almanac, operators)).result).to.equal(false)
- setup({ operator: 'greaterThan' }, 50)
- expect((await condition.evaluate(almanac, operators)).result).to.equal(false)
- })
-
- it('evaluates "greaterThanInclusive"', async () => {
- setup({ operator: 'greaterThanInclusive' }, 51)
- expect((await condition.evaluate(almanac, operators)).result).to.equal(true)
- setup({ operator: 'greaterThanInclusive' }, 50)
- expect((await condition.evaluate(almanac, operators)).result).to.equal(true)
- setup({ operator: 'greaterThanInclusive' }, 49)
- expect((await condition.evaluate(almanac, operators)).result).to.equal(false)
- })
-
- describe('invalid comparisonValues', () => {
- it('returns false when using contains or doesNotContain with a non-array', async () => {
- setup({ operator: 'contains' }, null)
- expect((await condition.evaluate(almanac, operators)).result).to.equal(false)
- setup({ operator: 'doesNotContain' }, null)
- expect((await condition.evaluate(almanac, operators)).result).to.equal(false)
- })
-
- it('returns false when using comparison operators with null', async () => {
- setup({ operator: 'lessThan' }, null)
- expect((await condition.evaluate(almanac, operators)).result).to.equal(false)
- setup({ operator: 'lessThanInclusive' }, null)
- expect((await condition.evaluate(almanac, operators)).result).to.equal(false)
- setup({ operator: 'greaterThan' }, null)
- expect((await condition.evaluate(almanac, operators)).result).to.equal(false)
- setup({ operator: 'greaterThanInclusive' }, null)
- expect((await condition.evaluate(almanac, operators)).result).to.equal(false)
- })
-
- it('returns false when using comparison operators with non-numbers', async () => {
- setup({ operator: 'lessThan' }, 'non-number')
- expect((await condition.evaluate(almanac, operators)).result).to.equal(false)
- setup({ operator: 'lessThan' }, null)
- expect((await condition.evaluate(almanac, operators)).result).to.equal(false)
- setup({ operator: 'lessThan' }, [])
- expect((await condition.evaluate(almanac, operators)).result).to.equal(false)
- setup({ operator: 'lessThan' }, {})
- expect((await condition.evaluate(almanac, operators)).result).to.equal(false)
- })
- })
- })
-
- describe('objects', () => {
- describe('.path', () => {
- it('extracts the object property values using its "path" property', async () => {
- const condition = new Condition({ operator: 'equal', path: '$.[0].id', fact: 'age', value: 50 })
- const ageFact = new Fact('age', [{ id: 50 }, { id: 60 }])
- const almanac = new Almanac()
- almanac.addFact(ageFact)
- expect((await condition.evaluate(almanac, operators)).result).to.equal(true)
-
- condition.value = 100 // negative case
- expect((await condition.evaluate(almanac, operators)).result).to.equal(false)
- })
-
- it('ignores "path" when non-objects are returned by the fact', async () => {
- const ageFact = new Fact('age', 50)
- const almanac = new Almanac()
- almanac.addFact(ageFact)
-
- const condition = new Condition({ operator: 'equal', path: '$.[0].id', fact: 'age', value: 50 })
- expect((await condition.evaluate(almanac, operators, 50)).result).to.equal(true)
-
- condition.value = 100 // negative case
- expect((await condition.evaluate(almanac, operators, 50)).result).to.equal(false)
- })
- })
-
- describe('jsonPath', () => {
- it('allows json path to extract values from complex facts', async () => {
- const condition = new Condition({ operator: 'contains', path: '$.phoneNumbers[*].type', fact: 'users', value: 'iPhone' })
- const userData = {
- phoneNumbers: [
- {
- type: 'iPhone',
- number: '0123-4567-8888'
- },
- {
- type: 'home',
- number: '0123-4567-8910'
- }
- ]
- }
-
- const usersFact = new Fact('users', userData)
- const almanac = new Almanac()
- almanac.addFact(usersFact)
- expect((await condition.evaluate(almanac, operators)).result).to.equal(true)
-
- condition.value = 'work' // negative case
- expect((await condition.evaluate(almanac, operators)).result).to.equal(false)
- })
- })
- })
-
- describe('boolean operators', () => {
- it('throws if not not an array', () => {
- const conditions = condition()
- conditions.all = { foo: true }
- expect(() => new Condition(conditions)).to.throw(/"all" must be an array/)
- })
-
- it('throws if is an array and condition is "not"', () => {
- const conditions = {
- not: [{ foo: true }]
- }
- expect(() => new Condition(conditions)).to.throw(/"not" cannot be an array/)
- })
-
- it('does not throw if is not an array and condition is "not"', () => {
- const conditions = {
- not: {
- fact: 'foo',
- operator: 'equal',
- value: 'bar'
- }
- }
- expect(() => new Condition(conditions)).to.not.throw()
- })
- })
-
- describe('atomic facts', () => {
- it('throws if no options are provided', () => {
- expect(() => new Condition()).to.throw(/Condition: constructor options required/)
- })
-
- it('throws for a missing "operator"', () => {
- const conditions = condition()
- delete conditions.all[0].operator
- expect(() => new Condition(conditions)).to.throw(/Condition: constructor "operator" property required/)
- })
-
- it('throws for a missing "fact"', () => {
- const conditions = condition()
- delete conditions.all[0].fact
- expect(() => new Condition(conditions)).to.throw(/Condition: constructor "fact" property required/)
- })
-
- it('throws for a missing "value"', () => {
- const conditions = condition()
- delete conditions.all[0].value
- expect(() => new Condition(conditions)).to.throw(/Condition: constructor "value" property required/)
- })
- })
-
- describe('complex conditions', () => {
- function complexCondition () {
- return {
- all: [
- {
- fact: 'age',
- operator: 'lessThan',
- value: 45
- },
- {
- fact: 'pointBalance',
- operator: 'greaterThanInclusive',
- value: 1000
- },
- {
- any: [
- {
- fact: 'gender',
- operator: 'equal',
- value: 'female'
- },
- {
- fact: 'income',
- operator: 'greaterThanInclusive',
- value: 50000
- }
- ]
- }
- ]
- }
- }
- it('recursively parses nested conditions', () => {
- expect(() => new Condition(complexCondition())).to.not.throw()
- })
-
- it('throws if a nested condition is invalid', () => {
- const conditions = complexCondition()
- delete conditions.all[2].any[0].fact
- expect(() => new Condition(conditions)).to.throw(/Condition: constructor "fact" property required/)
- })
- })
-})
diff --git a/test/condition.test.mjs b/test/condition.test.mjs
new file mode 100644
index 00000000..2e1d4298
--- /dev/null
+++ b/test/condition.test.mjs
@@ -0,0 +1,459 @@
+import Condition from "../src/condition.mjs";
+import defaultOperators from "../src/engine-default-operators.mjs";
+import Almanac from "../src/almanac.mjs";
+import Fact from "../src/fact.mjs";
+import { describe, it, beforeEach, expect } from "vitest";
+import conditionFactory from "./support/condition-factory.mjs";
+
+const operators = new Map();
+defaultOperators.forEach((o) => operators.set(o.name, o));
+
+function condition() {
+ return {
+ all: [
+ {
+ id: "6ed20017-375f-40c9-a1d2-6d7e0f4733c5",
+ name: "team participation in form",
+ fact: "team_participation",
+ operator: "equal",
+ value: 50,
+ path: ".metrics[0].forum-posts",
+ },
+ ],
+ };
+}
+
+describe("Condition", () => {
+ describe("constructor", () => {
+ it("fact conditions have properties", () => {
+ const properties = condition();
+ const subject = new Condition(properties.all[0]);
+ expect(subject).toHaveProperty("fact");
+ expect(subject).toHaveProperty("operator");
+ expect(subject).toHaveProperty("value");
+ expect(subject).toHaveProperty("path");
+ expect(subject).toHaveProperty("name");
+ });
+
+ it("boolean conditions have properties", () => {
+ const properties = condition();
+ const subject = new Condition(properties);
+ expect(subject).toHaveProperty("operator");
+ expect(subject).toHaveProperty("priority");
+ expect(subject.priority).toBe(1);
+ });
+ });
+
+ describe("toJSON", () => {
+ it("converts the condition into a json string", () => {
+ const properties = conditionFactory({
+ fact: "age",
+ value: {
+ fact: "weight",
+ params: {
+ unit: "lbs",
+ },
+ path: ".value",
+ },
+ });
+ const condition = new Condition(properties);
+ const json = condition.toJSON();
+ expect(json).toBe(
+ '{"operator":"equal","value":{"fact":"weight","params":{"unit":"lbs"},"path":".value"},"fact":"age"}',
+ );
+ });
+
+ it('converts "not" conditions', () => {
+ const properties = {
+ not: {
+ ...conditionFactory({
+ fact: "age",
+ value: {
+ fact: "weight",
+ params: {
+ unit: "lbs",
+ },
+ path: ".value",
+ },
+ }),
+ },
+ };
+ const condition = new Condition(properties);
+ const json = condition.toJSON();
+ expect(json).toBe(
+ '{"priority":1,"not":{"operator":"equal","value":{"fact":"weight","params":{"unit":"lbs"},"path":".value"},"fact":"age"}}',
+ );
+ });
+ });
+
+ describe("evaluate", () => {
+ const conditionBase = conditionFactory({
+ fact: "age",
+ value: 50,
+ });
+ let condition;
+ let almanac;
+ function setup(options, factValue) {
+ const properties = Object.assign({}, conditionBase, options);
+ condition = new Condition(properties);
+ const fact = new Fact(conditionBase.fact, factValue);
+ almanac = new Almanac();
+ almanac.addFact(fact);
+ }
+
+ describe("validations", () => {
+ beforeEach(() => setup({}, 1));
+ it("throws when missing an almanac", () => {
+ return expect(condition.evaluate(undefined, operators)).rejects.toThrow(
+ "almanac required",
+ );
+ });
+ it("throws when missing operators", () => {
+ return expect(condition.evaluate(almanac, undefined)).rejects.toThrow(
+ "operatorMap required",
+ );
+ });
+ it("throws when run against a boolean operator", () => {
+ condition.all = [];
+ return expect(condition.evaluate(almanac, operators)).rejects.toThrow(
+ "Cannot evaluate() a boolean condition",
+ );
+ });
+ });
+
+ it('evaluates "equal"', async () => {
+ setup({ operator: "equal" }, 50);
+ expect((await condition.evaluate(almanac, operators, 50)).result).toBe(
+ true,
+ );
+ setup({ operator: "equal" }, 5);
+ expect((await condition.evaluate(almanac, operators, 5)).result).toBe(
+ false,
+ );
+ });
+
+ it('evaluates "equal" to check for undefined', async () => {
+ condition = new Condition({
+ fact: "age",
+ operator: "equal",
+ value: undefined,
+ });
+ let fact = new Fact("age", undefined);
+ almanac = new Almanac();
+ almanac.addFact(fact);
+
+ expect((await condition.evaluate(almanac, operators)).result).toBe(true);
+
+ fact = new Fact("age", 1);
+ almanac = new Almanac();
+ almanac.addFact(fact);
+ expect((await condition.evaluate(almanac, operators)).result).toBe(false);
+ });
+
+ it('evaluates "notEqual"', async () => {
+ setup({ operator: "notEqual" }, 50);
+ expect((await condition.evaluate(almanac, operators)).result).toBe(false);
+ setup({ operator: "notEqual" }, 5);
+ expect((await condition.evaluate(almanac, operators)).result).toBe(true);
+ });
+
+ it('evaluates "in"', async () => {
+ setup({ operator: "in", value: [5, 10, 15, 20] }, 15);
+ expect((await condition.evaluate(almanac, operators)).result).toBe(true);
+ setup({ operator: "in", value: [5, 10, 15, 20] }, 99);
+ expect((await condition.evaluate(almanac, operators)).result).toBe(false);
+ });
+
+ it('evaluates "contains"', async () => {
+ setup({ operator: "contains", value: 10 }, [5, 10, 15]);
+ expect((await condition.evaluate(almanac, operators)).result).toBe(true);
+ setup({ operator: "contains", value: 10 }, [1, 2, 3]);
+ expect((await condition.evaluate(almanac, operators)).result).toBe(false);
+ });
+
+ it('evaluates "doesNotContain"', async () => {
+ setup({ operator: "doesNotContain", value: 10 }, [5, 10, 15]);
+ expect((await condition.evaluate(almanac, operators)).result).toBe(false);
+ setup({ operator: "doesNotContain", value: 10 }, [1, 2, 3]);
+ expect((await condition.evaluate(almanac, operators)).result).toBe(true);
+ });
+
+ it('evaluates "notIn"', async () => {
+ setup({ operator: "notIn", value: [5, 10, 15, 20] }, 15);
+ expect((await condition.evaluate(almanac, operators)).result).toBe(false);
+ setup({ operator: "notIn", value: [5, 10, 15, 20] }, 99);
+ expect((await condition.evaluate(almanac, operators)).result).toBe(true);
+ });
+
+ it('evaluates "lessThan"', async () => {
+ setup({ operator: "lessThan" }, 49);
+ expect((await condition.evaluate(almanac, operators)).result).toBe(true);
+ setup({ operator: "lessThan" }, 50);
+ expect((await condition.evaluate(almanac, operators)).result).toBe(false);
+ setup({ operator: "lessThan" }, 51);
+ expect((await condition.evaluate(almanac, operators)).result).toBe(false);
+ });
+
+ it('evaluates "lessThanInclusive"', async () => {
+ setup({ operator: "lessThanInclusive" }, 49);
+ expect((await condition.evaluate(almanac, operators)).result).toBe(true);
+ setup({ operator: "lessThanInclusive" }, 50);
+ expect((await condition.evaluate(almanac, operators)).result).toBe(true);
+ setup({ operator: "lessThanInclusive" }, 51);
+ expect((await condition.evaluate(almanac, operators)).result).toBe(false);
+ });
+
+ it('evaluates "greaterThan"', async () => {
+ setup({ operator: "greaterThan" }, 51);
+ expect((await condition.evaluate(almanac, operators)).result).toBe(true);
+ setup({ operator: "greaterThan" }, 49);
+ expect((await condition.evaluate(almanac, operators)).result).toBe(false);
+ setup({ operator: "greaterThan" }, 50);
+ expect((await condition.evaluate(almanac, operators)).result).toBe(false);
+ });
+
+ it('evaluates "greaterThanInclusive"', async () => {
+ setup({ operator: "greaterThanInclusive" }, 51);
+ expect((await condition.evaluate(almanac, operators)).result).toBe(true);
+ setup({ operator: "greaterThanInclusive" }, 50);
+ expect((await condition.evaluate(almanac, operators)).result).toBe(true);
+ setup({ operator: "greaterThanInclusive" }, 49);
+ expect((await condition.evaluate(almanac, operators)).result).toBe(false);
+ });
+
+ describe("invalid comparisonValues", () => {
+ it("returns false when using contains or doesNotContain with a non-array", async () => {
+ setup({ operator: "contains" }, null);
+ expect((await condition.evaluate(almanac, operators)).result).toBe(
+ false,
+ );
+ setup({ operator: "doesNotContain" }, null);
+ expect((await condition.evaluate(almanac, operators)).result).toBe(
+ false,
+ );
+ });
+
+ it("returns false when using comparison operators with null", async () => {
+ setup({ operator: "lessThan" }, null);
+ expect((await condition.evaluate(almanac, operators)).result).toBe(
+ false,
+ );
+ setup({ operator: "lessThanInclusive" }, null);
+ expect((await condition.evaluate(almanac, operators)).result).toBe(
+ false,
+ );
+ setup({ operator: "greaterThan" }, null);
+ expect((await condition.evaluate(almanac, operators)).result).toBe(
+ false,
+ );
+ setup({ operator: "greaterThanInclusive" }, null);
+ expect((await condition.evaluate(almanac, operators)).result).toBe(
+ false,
+ );
+ });
+
+ it("returns false when using comparison operators with non-numbers", async () => {
+ setup({ operator: "lessThan" }, "non-number");
+ expect((await condition.evaluate(almanac, operators)).result).toBe(
+ false,
+ );
+ setup({ operator: "lessThan" }, null);
+ expect((await condition.evaluate(almanac, operators)).result).toBe(
+ false,
+ );
+ setup({ operator: "lessThan" }, []);
+ expect((await condition.evaluate(almanac, operators)).result).toBe(
+ false,
+ );
+ setup({ operator: "lessThan" }, {});
+ expect((await condition.evaluate(almanac, operators)).result).toBe(
+ false,
+ );
+ });
+ });
+ });
+
+ describe("objects", () => {
+ describe(".path", () => {
+ it('extracts the object property values using its "path" property', async () => {
+ const condition = new Condition({
+ operator: "equal",
+ path: "$.[0].id",
+ fact: "age",
+ value: 50,
+ });
+ const ageFact = new Fact("age", [{ id: 50 }, { id: 60 }]);
+ const almanac = new Almanac();
+ almanac.addFact(ageFact);
+ expect((await condition.evaluate(almanac, operators)).result).toBe(
+ true,
+ );
+
+ condition.value = 100; // negative case
+ expect((await condition.evaluate(almanac, operators)).result).toBe(
+ false,
+ );
+ });
+
+ it('ignores "path" when non-objects are returned by the fact', async () => {
+ const ageFact = new Fact("age", 50);
+ const almanac = new Almanac();
+ almanac.addFact(ageFact);
+
+ const condition = new Condition({
+ operator: "equal",
+ path: "$.[0].id",
+ fact: "age",
+ value: 50,
+ });
+ expect((await condition.evaluate(almanac, operators, 50)).result).toBe(
+ true,
+ );
+
+ condition.value = 100; // negative case
+ expect((await condition.evaluate(almanac, operators, 50)).result).toBe(
+ false,
+ );
+ });
+ });
+
+ describe("jsonPath", () => {
+ it("allows json path to extract values from complex facts", async () => {
+ const condition = new Condition({
+ operator: "contains",
+ path: "$.phoneNumbers[*].type",
+ fact: "users",
+ value: "iPhone",
+ });
+ const userData = {
+ phoneNumbers: [
+ {
+ type: "iPhone",
+ number: "0123-4567-8888",
+ },
+ {
+ type: "home",
+ number: "0123-4567-8910",
+ },
+ ],
+ };
+
+ const usersFact = new Fact("users", userData);
+ const almanac = new Almanac();
+ almanac.addFact(usersFact);
+ expect((await condition.evaluate(almanac, operators)).result).toBe(
+ true,
+ );
+
+ condition.value = "work"; // negative case
+ expect((await condition.evaluate(almanac, operators)).result).toBe(
+ false,
+ );
+ });
+ });
+ });
+
+ describe("boolean operators", () => {
+ it("throws if not not an array", () => {
+ const conditions = condition();
+ conditions.all = { foo: true };
+ expect(() => new Condition(conditions)).toThrow(/"all" must be an array/);
+ });
+
+ it('throws if is an array and condition is "not"', () => {
+ const conditions = {
+ not: [{ foo: true }],
+ };
+ expect(() => new Condition(conditions)).toThrow(
+ /"not" cannot be an array/,
+ );
+ });
+
+ it('does not throw if is not an array and condition is "not"', () => {
+ const conditions = {
+ not: {
+ fact: "foo",
+ operator: "equal",
+ value: "bar",
+ },
+ };
+ expect(() => new Condition(conditions)).not.toThrow();
+ });
+ });
+
+ describe("atomic facts", () => {
+ it("throws if no options are provided", () => {
+ expect(() => new Condition()).toThrow(
+ /Condition: constructor options required/,
+ );
+ });
+
+ it('throws for a missing "operator"', () => {
+ const conditions = condition();
+ delete conditions.all[0].operator;
+ expect(() => new Condition(conditions)).toThrow(
+ /Condition: constructor "operator" property required/,
+ );
+ });
+
+ it('throws for a missing "fact"', () => {
+ const conditions = condition();
+ delete conditions.all[0].fact;
+ expect(() => new Condition(conditions)).toThrow(
+ /Condition: constructor "fact" property required/,
+ );
+ });
+
+ it('throws for a missing "value"', () => {
+ const conditions = condition();
+ delete conditions.all[0].value;
+ expect(() => new Condition(conditions)).toThrow(
+ /Condition: constructor "value" property required/,
+ );
+ });
+ });
+
+ describe("complex conditions", () => {
+ function complexCondition() {
+ return {
+ all: [
+ {
+ fact: "age",
+ operator: "lessThan",
+ value: 45,
+ },
+ {
+ fact: "pointBalance",
+ operator: "greaterThanInclusive",
+ value: 1000,
+ },
+ {
+ any: [
+ {
+ fact: "gender",
+ operator: "equal",
+ value: "female",
+ },
+ {
+ fact: "income",
+ operator: "greaterThanInclusive",
+ value: 50000,
+ },
+ ],
+ },
+ ],
+ };
+ }
+ it("recursively parses nested conditions", () => {
+ expect(() => new Condition(complexCondition())).not.toThrow();
+ });
+
+ it("throws if a nested condition is invalid", () => {
+ const conditions = complexCondition();
+ delete conditions.all[2].any[0].fact;
+ expect(() => new Condition(conditions)).toThrow(
+ /Condition: constructor "fact" property required/,
+ );
+ });
+ });
+});
diff --git a/test/engine-all.test.js b/test/engine-all.test.js
deleted file mode 100644
index d82db875..00000000
--- a/test/engine-all.test.js
+++ /dev/null
@@ -1,111 +0,0 @@
-'use strict'
-
-import sinon from 'sinon'
-import engineFactory from '../src/index'
-
-async function factSenior (params, engine) {
- return 65
-}
-
-async function factChild (params, engine) {
- return 10
-}
-
-async function factAdult (params, engine) {
- return 30
-}
-
-describe('Engine: "all" conditions', () => {
- let engine
- let sandbox
- before(() => {
- sandbox = sinon.createSandbox()
- })
- afterEach(() => {
- sandbox.restore()
- })
-
- describe('supports a single "all" condition', () => {
- const event = {
- type: 'ageTrigger',
- params: {
- demographic: 'under50'
- }
- }
- const conditions = {
- all: [{
- fact: 'age',
- operator: 'lessThan',
- value: 50
- }]
- }
- let eventSpy
- beforeEach(() => {
- eventSpy = sandbox.spy()
- const rule = factories.rule({ conditions, event })
- engine = engineFactory()
- engine.addRule(rule)
- engine.on('success', eventSpy)
- })
-
- it('emits when the condition is met', async () => {
- engine.addFact('age', factChild)
- await engine.run()
- expect(eventSpy).to.have.been.calledWith(event)
- })
-
- it('does not emit when the condition fails', () => {
- engine.addFact('age', factSenior)
- engine.run()
- expect(eventSpy).to.not.have.been.calledWith(event)
- })
- })
-
- describe('supports "any" with multiple conditions', () => {
- const conditions = {
- all: [{
- fact: 'age',
- operator: 'lessThan',
- value: 50
- }, {
- fact: 'age',
- operator: 'greaterThan',
- value: 21
- }]
- }
- const event = {
- type: 'ageTrigger',
- params: {
- demographic: 'adult'
- }
- }
- let eventSpy
- beforeEach(() => {
- eventSpy = sandbox.spy()
- const rule = factories.rule({ conditions, event })
- engine = engineFactory()
- engine.addRule(rule)
- engine.on('success', eventSpy)
- })
-
- it('emits an event when every condition is met', async () => {
- engine.addFact('age', factAdult)
- await engine.run()
- expect(eventSpy).to.have.been.calledWith(event)
- })
-
- describe('a condition fails', () => {
- it('does not emit when the first condition fails', async () => {
- engine.addFact('age', factChild)
- await engine.run()
- expect(eventSpy).to.not.have.been.calledWith(event)
- })
-
- it('does not emit when the second condition', async () => {
- engine.addFact('age', factSenior)
- await engine.run()
- expect(eventSpy).to.not.have.been.calledWith(event)
- })
- })
- })
-})
diff --git a/test/engine-all.test.mjs b/test/engine-all.test.mjs
new file mode 100644
index 00000000..ea4e9de6
--- /dev/null
+++ b/test/engine-all.test.mjs
@@ -0,0 +1,128 @@
+import engineFactory from "../src/index.mjs";
+import { describe, it, beforeEach, expect, vi } from "vitest";
+import ruleFactory from "./support/rule-factory.mjs";
+
+async function factSenior() {
+ return 65;
+}
+
+async function factChild() {
+ return 10;
+}
+
+async function factAdult() {
+ return 30;
+}
+
+describe('Engine: "all" conditions', () => {
+ let engine;
+
+ describe('supports a single "all" condition', () => {
+ const event = {
+ type: "ageTrigger",
+ params: {
+ demographic: "under50",
+ },
+ };
+ const conditions = {
+ all: [
+ {
+ fact: "age",
+ operator: "lessThan",
+ value: 50,
+ },
+ ],
+ };
+ let eventSpy;
+ beforeEach(() => {
+ eventSpy = vi.fn();
+ const rule = ruleFactory({ conditions, event });
+ engine = engineFactory();
+ engine.addRule(rule);
+ engine.on("success", eventSpy);
+ });
+
+ it("emits when the condition is met", async () => {
+ engine.addFact("age", factChild);
+ await engine.run();
+ expect(eventSpy).toHaveBeenCalledWith(
+ event,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it("does not emit when the condition fails", () => {
+ engine.addFact("age", factSenior);
+ engine.run();
+ expect(eventSpy).not.toHaveBeenCalledWith(
+ event,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+ });
+
+ describe('supports "any" with multiple conditions', () => {
+ const conditions = {
+ all: [
+ {
+ fact: "age",
+ operator: "lessThan",
+ value: 50,
+ },
+ {
+ fact: "age",
+ operator: "greaterThan",
+ value: 21,
+ },
+ ],
+ };
+ const event = {
+ type: "ageTrigger",
+ params: {
+ demographic: "adult",
+ },
+ };
+ let eventSpy;
+ beforeEach(() => {
+ eventSpy = vi.fn();
+ const rule = ruleFactory({ conditions, event });
+ engine = engineFactory();
+ engine.addRule(rule);
+ engine.on("success", eventSpy);
+ });
+
+ it("emits an event when every condition is met", async () => {
+ engine.addFact("age", factAdult);
+ await engine.run();
+ expect(eventSpy).toHaveBeenCalledWith(
+ event,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ describe("a condition fails", () => {
+ it("does not emit when the first condition fails", async () => {
+ engine.addFact("age", factChild);
+ await engine.run();
+ expect(eventSpy).not.toHaveBeenCalledWith(
+ event,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it("does not emit when the second condition", async () => {
+ engine.addFact("age", factSenior);
+ await engine.run();
+ expect(eventSpy).not.toHaveBeenCalledWith(
+ event,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+ });
+ });
+});
diff --git a/test/engine-any.test.js b/test/engine-any.test.js
deleted file mode 100644
index 1b722570..00000000
--- a/test/engine-any.test.js
+++ /dev/null
@@ -1,107 +0,0 @@
-'use strict'
-
-import sinon from 'sinon'
-import engineFactory from '../src/index'
-
-describe('Engine: "any" conditions', () => {
- let engine
- let sandbox
- before(() => {
- sandbox = sinon.createSandbox()
- })
- afterEach(() => {
- sandbox.restore()
- })
-
- describe('supports a single "any" condition', () => {
- const event = {
- type: 'ageTrigger',
- params: {
- demographic: 'under50'
- }
- }
- const conditions = {
- any: [{
- fact: 'age',
- operator: 'lessThan',
- value: 50
- }]
- }
- let eventSpy
- let ageSpy
- beforeEach(() => {
- eventSpy = sandbox.spy()
- ageSpy = sandbox.stub()
- const rule = factories.rule({ conditions, event })
- engine = engineFactory()
- engine.addRule(rule)
- engine.addFact('age', ageSpy)
- engine.on('success', eventSpy)
- })
-
- it('emits when the condition is met', async () => {
- ageSpy.returns(10)
- await engine.run()
- expect(eventSpy).to.have.been.calledWith(event)
- })
-
- it('does not emit when the condition fails', () => {
- ageSpy.returns(75)
- engine.run()
- expect(eventSpy).to.not.have.been.calledWith(event)
- })
- })
-
- describe('supports "any" with multiple conditions', () => {
- const conditions = {
- any: [{
- fact: 'age',
- operator: 'lessThan',
- value: 50
- }, {
- fact: 'segment',
- operator: 'equal',
- value: 'european'
- }]
- }
- const event = {
- type: 'ageTrigger',
- params: {
- demographic: 'under50'
- }
- }
- let eventSpy
- let ageSpy
- let segmentSpy
- beforeEach(() => {
- eventSpy = sandbox.spy()
- ageSpy = sandbox.stub()
- segmentSpy = sandbox.stub()
- const rule = factories.rule({ conditions, event })
- engine = engineFactory()
- engine.addRule(rule)
- engine.addFact('segment', segmentSpy)
- engine.addFact('age', ageSpy)
- engine.on('success', eventSpy)
- })
-
- it('emits an event when any condition is met', async () => {
- segmentSpy.returns('north-american')
- ageSpy.returns(25)
- await engine.run()
- expect(eventSpy).to.have.been.calledWith(event)
-
- segmentSpy.returns('european')
- ageSpy.returns(100)
- await engine.run()
- expect(eventSpy).to.have.been.calledWith(event)
- })
-
- it('does not emit when all conditions fail', async () => {
- segmentSpy.returns('north-american')
- ageSpy.returns(100)
- await engine.run()
- expect(eventSpy).to.not.have.been.calledWith(event)
- })
- })
-})
diff --git a/test/engine-any.test.mjs b/test/engine-any.test.mjs
new file mode 100644
index 00000000..440e95c2
--- /dev/null
+++ b/test/engine-any.test.mjs
@@ -0,0 +1,124 @@
+import engineFactory from "../src/index.mjs";
+import { describe, it, beforeEach, expect, vi } from "vitest";
+import ruleFactory from "./support/rule-factory.mjs";
+
+describe('Engine: "any" conditions', () => {
+ let engine;
+
+ describe('supports a single "any" condition', () => {
+ const event = {
+ type: "ageTrigger",
+ params: {
+ demographic: "under50",
+ },
+ };
+ const conditions = {
+ any: [
+ {
+ fact: "age",
+ operator: "lessThan",
+ value: 50,
+ },
+ ],
+ };
+ let eventSpy;
+ let ageSpy;
+ beforeEach(() => {
+ eventSpy = vi.fn();
+ ageSpy = vi.fn();
+ const rule = ruleFactory({ conditions, event });
+ engine = engineFactory();
+ engine.addRule(rule);
+ engine.addFact("age", ageSpy);
+ engine.on("success", eventSpy);
+ });
+
+ it("emits when the condition is met", async () => {
+ ageSpy.mockReturnValue(10);
+ await engine.run();
+ expect(eventSpy).toHaveBeenCalledWith(
+ event,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it("does not emit when the condition fails", () => {
+ ageSpy.mockReturnValue(75);
+ engine.run();
+ expect(eventSpy).not.toHaveBeenCalledWith(
+ event,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+ });
+
+ describe('supports "any" with multiple conditions', () => {
+ const conditions = {
+ any: [
+ {
+ fact: "age",
+ operator: "lessThan",
+ value: 50,
+ },
+ {
+ fact: "segment",
+ operator: "equal",
+ value: "european",
+ },
+ ],
+ };
+ const event = {
+ type: "ageTrigger",
+ params: {
+ demographic: "under50",
+ },
+ };
+ let eventSpy;
+ let ageSpy;
+ let segmentSpy;
+ beforeEach(() => {
+ eventSpy = vi.fn();
+ ageSpy = vi.fn();
+ segmentSpy = vi.fn();
+ const rule = ruleFactory({ conditions, event });
+ engine = engineFactory();
+ engine.addRule(rule);
+ engine.addFact("segment", segmentSpy);
+ engine.addFact("age", ageSpy);
+ engine.on("success", eventSpy);
+ });
+
+ it("emits an event when any condition is met", async () => {
+ segmentSpy.mockReturnValue("north-american");
+ ageSpy.mockReturnValue(25);
+ await engine.run();
+ expect(eventSpy).toHaveBeenCalledWith(
+ event,
+ expect.anything(),
+ expect.anything(),
+ );
+
+ segmentSpy.mockReturnValue("european");
+ ageSpy.mockReturnValue(100);
+ await engine.run();
+ expect(eventSpy).toHaveBeenCalledWith(
+ event,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it("does not emit when all conditions fail", async () => {
+ segmentSpy.mockReturnValue("north-american");
+ ageSpy.mockReturnValue(100);
+ await engine.run();
+ expect(eventSpy).not.toHaveBeenCalledWith(
+ event,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+ });
+});
diff --git a/test/engine-cache.test.js b/test/engine-cache.test.js
deleted file mode 100644
index 7e9d7082..00000000
--- a/test/engine-cache.test.js
+++ /dev/null
@@ -1,59 +0,0 @@
-'use strict'
-
-import sinon from 'sinon'
-import engineFactory from '../src/index'
-
-describe('Engine: cache', () => {
- let engine
- let sandbox
- before(() => {
- sandbox = sinon.createSandbox()
- })
- afterEach(() => {
- sandbox.restore()
- })
-
- const event = { type: 'setDrinkingFlag' }
- const collegeSeniorEvent = { type: 'isCollegeSenior' }
- const conditions = {
- any: [{
- fact: 'age',
- operator: 'greaterThanInclusive',
- value: 21
- }]
- }
-
- let factSpy
- let eventSpy
- const ageFact = () => {
- factSpy()
- return 22
- }
- function setup (factOptions) {
- factSpy = sandbox.spy()
- eventSpy = sandbox.spy()
- engine = engineFactory()
- const determineDrinkingAge = factories.rule({ conditions, event, priority: 100 })
- engine.addRule(determineDrinkingAge)
- const determineCollegeSenior = factories.rule({ conditions, event: collegeSeniorEvent, priority: 1 })
- engine.addRule(determineCollegeSenior)
- const over20 = factories.rule({ conditions, event: collegeSeniorEvent, priority: 50 })
- engine.addRule(over20)
- engine.addFact('age', ageFact, factOptions)
- engine.on('success', eventSpy)
- }
-
- it('loads facts once and caches the results for future use', async () => {
- setup({ cache: true })
- await engine.run()
- expect(eventSpy).to.have.been.calledThrice()
- expect(factSpy).to.have.been.calledOnce()
- })
-
- it('allows caching to be turned off', async () => {
- setup({ cache: false })
- await engine.run()
- expect(eventSpy).to.have.been.calledThrice()
- expect(factSpy).to.have.been.calledThrice()
- })
-})
diff --git a/test/engine-cache.test.mjs b/test/engine-cache.test.mjs
new file mode 100644
index 00000000..dcd1a687
--- /dev/null
+++ b/test/engine-cache.test.mjs
@@ -0,0 +1,65 @@
+import engineFactory from "../src/index.mjs";
+import { describe, it, expect, vi } from "vitest";
+import ruleFactory from "./support/rule-factory.mjs";
+
+describe("Engine: cache", () => {
+ let engine;
+
+ const event = { type: "setDrinkingFlag" };
+ const collegeSeniorEvent = { type: "isCollegeSenior" };
+ const conditions = {
+ any: [
+ {
+ fact: "age",
+ operator: "greaterThanInclusive",
+ value: 21,
+ },
+ ],
+ };
+
+ let factSpy;
+ let eventSpy;
+ const ageFact = () => {
+ factSpy();
+ return 22;
+ };
+ function setup(factOptions) {
+ factSpy = vi.fn();
+ eventSpy = vi.fn();
+ engine = engineFactory();
+ const determineDrinkingAge = ruleFactory({
+ conditions,
+ event,
+ priority: 100,
+ });
+ engine.addRule(determineDrinkingAge);
+ const determineCollegeSenior = ruleFactory({
+ conditions,
+ event: collegeSeniorEvent,
+ priority: 1,
+ });
+ engine.addRule(determineCollegeSenior);
+ const over20 = ruleFactory({
+ conditions,
+ event: collegeSeniorEvent,
+ priority: 50,
+ });
+ engine.addRule(over20);
+ engine.addFact("age", ageFact, factOptions);
+ engine.on("success", eventSpy);
+ }
+
+ it("loads facts once and caches the results for future use", async () => {
+ setup({ cache: true });
+ await engine.run();
+ expect(eventSpy).toHaveBeenCalledTimes(3);
+ expect(factSpy).toHaveBeenCalledOnce();
+ });
+
+ it("allows caching to be turned off", async () => {
+ setup({ cache: false });
+ await engine.run();
+ expect(eventSpy).toHaveBeenCalledTimes(3);
+ expect(factSpy).toHaveBeenCalledTimes(3);
+ });
+});
diff --git a/test/engine-condition.test.js b/test/engine-condition.test.js
deleted file mode 100644
index 028a856e..00000000
--- a/test/engine-condition.test.js
+++ /dev/null
@@ -1,321 +0,0 @@
-'use strict'
-
-import sinon from 'sinon'
-import engineFactory from '../src/index'
-
-describe('Engine: condition', () => {
- let engine
- let sandbox
- before(() => {
- sandbox = sinon.createSandbox()
- })
- afterEach(() => {
- sandbox.restore()
- })
-
- describe('setCondition()', () => {
- describe('validations', () => {
- beforeEach(() => {
- engine = engineFactory()
- })
- it('throws an exception for invalid root conditions', () => {
- expect(engine.setCondition.bind(engine, 'test', { foo: true })).to.throw(
- /"conditions" root must contain a single instance of "all", "any", "not", or "condition"/
- )
- })
- })
- })
-
- describe('undefined condition', () => {
- const sendEvent = {
- type: 'checkSending',
- params: {
- sendRetirementPayment: true
- }
- }
-
- const sendConditions = {
- all: [
- { condition: 'over60' },
- {
- fact: 'isRetired',
- operator: 'equal',
- value: true
- }
- ]
- }
-
- describe('allowUndefinedConditions: true', () => {
- let eventSpy
- beforeEach(() => {
- eventSpy = sandbox.spy()
- const sendRule = factories.rule({
- conditions: sendConditions,
- event: sendEvent
- })
- engine = engineFactory([sendRule], { allowUndefinedConditions: true })
-
- engine.addFact('isRetired', true)
- engine.on('failure', eventSpy)
- })
-
- it('evaluates undefined conditions as false', async () => {
- await engine.run()
- expect(eventSpy).to.have.been.called()
- })
- })
-
- describe('allowUndefinedConditions: false', () => {
- beforeEach(() => {
- const sendRule = factories.rule({
- conditions: sendConditions,
- event: sendEvent
- })
- engine = engineFactory([sendRule], { allowUndefinedConditions: false })
-
- engine.addFact('isRetired', true)
- })
-
- it('throws error during run', async () => {
- try {
- await engine.run()
- } catch (error) {
- expect(error.message).to.equal('No condition over60 exists')
- }
- })
- })
- })
-
- describe('supports condition shared across multiple rules', () => {
- const name = 'over60'
- const condition = {
- all: [
- {
- fact: 'age',
- operator: 'greaterThanInclusive',
- value: 60
- }
- ]
- }
-
- const sendEvent = {
- type: 'checkSending',
- params: {
- sendRetirementPayment: true
- }
- }
-
- const sendConditions = {
- all: [
- { condition: name },
- {
- fact: 'isRetired',
- operator: 'equal',
- value: true
- }
- ]
- }
-
- const outreachEvent = {
- type: 'triggerOutreach'
- }
-
- const outreachConditions = {
- all: [
- { condition: name },
- {
- fact: 'requestedOutreach',
- operator: 'equal',
- value: true
- }
- ]
- }
-
- let eventSpy
- let ageSpy
- let isRetiredSpy
- let requestedOutreachSpy
- beforeEach(() => {
- eventSpy = sandbox.spy()
- ageSpy = sandbox.stub()
- isRetiredSpy = sandbox.stub()
- requestedOutreachSpy = sandbox.stub()
- engine = engineFactory()
-
- const sendRule = factories.rule({
- conditions: sendConditions,
- event: sendEvent
- })
- engine.addRule(sendRule)
-
- const outreachRule = factories.rule({
- conditions: outreachConditions,
- event: outreachEvent
- })
- engine.addRule(outreachRule)
-
- engine.setCondition(name, condition)
-
- engine.addFact('age', ageSpy)
- engine.addFact('isRetired', isRetiredSpy)
- engine.addFact('requestedOutreach', requestedOutreachSpy)
- engine.on('success', eventSpy)
- })
-
- it('emits all events when all conditions are met', async () => {
- ageSpy.returns(65)
- isRetiredSpy.returns(true)
- requestedOutreachSpy.returns(true)
- await engine.run()
- expect(eventSpy)
- .to.have.been.calledWith(sendEvent)
- .and.to.have.been.calledWith(outreachEvent)
- })
-
- it('expands condition in rule results', async () => {
- ageSpy.returns(65)
- isRetiredSpy.returns(true)
- requestedOutreachSpy.returns(true)
- const { results } = await engine.run()
- const nestedCondition = {
- 'conditions.all[0].all[0].fact': 'age',
- 'conditions.all[0].all[0].operator': 'greaterThanInclusive',
- 'conditions.all[0].all[0].value': 60
- }
- expect(results[0]).to.nested.include(nestedCondition)
- expect(results[1]).to.nested.include(nestedCondition)
- })
- })
-
- describe('nested condition', () => {
- const name1 = 'over60'
- const condition1 = {
- all: [
- {
- fact: 'age',
- operator: 'greaterThanInclusive',
- value: 60
- }
- ]
- }
-
- const name2 = 'earlyRetirement'
- const condition2 = {
- all: [
- { not: { condition: name1 } },
- {
- fact: 'isRetired',
- operator: 'equal',
- value: true
- }
- ]
- }
-
- const outreachEvent = {
- type: 'triggerOutreach'
- }
-
- const outreachConditions = {
- all: [
- { condition: name2 },
- {
- fact: 'requestedOutreach',
- operator: 'equal',
- value: true
- }
- ]
- }
-
- let eventSpy
- let ageSpy
- let isRetiredSpy
- let requestedOutreachSpy
- beforeEach(() => {
- eventSpy = sandbox.spy()
- ageSpy = sandbox.stub()
- isRetiredSpy = sandbox.stub()
- requestedOutreachSpy = sandbox.stub()
- engine = engineFactory()
-
- const outreachRule = factories.rule({
- conditions: outreachConditions,
- event: outreachEvent
- })
- engine.addRule(outreachRule)
-
- engine.setCondition(name1, condition1)
-
- engine.setCondition(name2, condition2)
-
- engine.addFact('age', ageSpy)
- engine.addFact('isRetired', isRetiredSpy)
- engine.addFact('requestedOutreach', requestedOutreachSpy)
- engine.on('success', eventSpy)
- })
-
- it('emits all events when all conditions are met', async () => {
- ageSpy.returns(55)
- isRetiredSpy.returns(true)
- requestedOutreachSpy.returns(true)
- await engine.run()
- expect(eventSpy).to.have.been.calledWith(outreachEvent)
- })
-
- it('expands condition in rule results', async () => {
- ageSpy.returns(55)
- isRetiredSpy.returns(true)
- requestedOutreachSpy.returns(true)
- const { results } = await engine.run()
- const nestedCondition = {
- 'conditions.all[0].all[0].not.all[0].fact': 'age',
- 'conditions.all[0].all[0].not.all[0].operator': 'greaterThanInclusive',
- 'conditions.all[0].all[0].not.all[0].value': 60,
- 'conditions.all[0].all[1].fact': 'isRetired',
- 'conditions.all[0].all[1].operator': 'equal',
- 'conditions.all[0].all[1].value': true
- }
- expect(results[0]).to.nested.include(nestedCondition)
- })
- })
-
- describe('top-level condition reference', () => {
- const sendEvent = {
- type: 'checkSending',
- params: {
- sendRetirementPayment: true
- }
- }
-
- const retiredName = 'retired'
- const retiredCondition = {
- all: [
- { fact: 'isRetired', operator: 'equal', value: true }
- ]
- }
-
- const sendConditions = {
- condition: retiredName
- }
-
- let eventSpy
- beforeEach(() => {
- eventSpy = sandbox.spy()
- const sendRule = factories.rule({
- conditions: sendConditions,
- event: sendEvent
- })
- engine = engineFactory()
-
- engine.addRule(sendRule)
- engine.setCondition(retiredName, retiredCondition)
-
- engine.addFact('isRetired', true)
- engine.on('success', eventSpy)
- })
-
- it('evaluates top level conditions correctly', async () => {
- await engine.run()
- expect(eventSpy).to.have.been.called()
- })
- })
-})
diff --git a/test/engine-condition.test.mjs b/test/engine-condition.test.mjs
new file mode 100644
index 00000000..0662a307
--- /dev/null
+++ b/test/engine-condition.test.mjs
@@ -0,0 +1,359 @@
+import engineFactory from "../src/index.mjs";
+import { describe, it, beforeEach, expect, vi } from "vitest";
+import ruleFactory from "./support/rule-factory.mjs";
+
+describe("Engine: condition", () => {
+ let engine;
+
+ describe("setCondition()", () => {
+ describe("validations", () => {
+ beforeEach(() => {
+ engine = engineFactory();
+ });
+ it("throws an exception for invalid root conditions", () => {
+ expect(engine.setCondition.bind(engine, "test", { foo: true })).toThrow(
+ /"conditions" root must contain a single instance of "all", "any", "not", or "condition"/,
+ );
+ });
+ });
+ });
+
+ describe("undefined condition", () => {
+ const sendEvent = {
+ type: "checkSending",
+ params: {
+ sendRetirementPayment: true,
+ },
+ };
+
+ const sendConditions = {
+ all: [
+ { condition: "over60" },
+ {
+ fact: "isRetired",
+ operator: "equal",
+ value: true,
+ },
+ ],
+ };
+
+ describe("allowUndefinedConditions: true", () => {
+ let eventSpy;
+ beforeEach(() => {
+ eventSpy = vi.fn();
+ const sendRule = ruleFactory({
+ conditions: sendConditions,
+ event: sendEvent,
+ });
+ engine = engineFactory([sendRule], { allowUndefinedConditions: true });
+
+ engine.addFact("isRetired", true);
+ engine.on("failure", eventSpy);
+ });
+
+ it("evaluates undefined conditions as false", async () => {
+ await engine.run();
+ expect(eventSpy).toHaveBeenCalled();
+ });
+ });
+
+ describe("allowUndefinedConditions: false", () => {
+ beforeEach(() => {
+ const sendRule = ruleFactory({
+ conditions: sendConditions,
+ event: sendEvent,
+ });
+ engine = engineFactory([sendRule], { allowUndefinedConditions: false });
+
+ engine.addFact("isRetired", true);
+ });
+
+ it("throws error during run", async () => {
+ try {
+ await engine.run();
+ } catch (error) {
+ expect(error.message).toBe("No condition over60 exists");
+ }
+ });
+ });
+ });
+
+ describe("supports condition shared across multiple rules", () => {
+ const name = "over60";
+ const condition = {
+ all: [
+ {
+ fact: "age",
+ operator: "greaterThanInclusive",
+ value: 60,
+ },
+ ],
+ };
+
+ const sendEvent = {
+ type: "checkSending",
+ params: {
+ sendRetirementPayment: true,
+ },
+ };
+
+ const sendConditions = {
+ all: [
+ { condition: name },
+ {
+ fact: "isRetired",
+ operator: "equal",
+ value: true,
+ },
+ ],
+ };
+
+ const outreachEvent = {
+ type: "triggerOutreach",
+ };
+
+ const outreachConditions = {
+ all: [
+ { condition: name },
+ {
+ fact: "requestedOutreach",
+ operator: "equal",
+ value: true,
+ },
+ ],
+ };
+
+ let eventSpy;
+ let ageSpy;
+ let isRetiredSpy;
+ let requestedOutreachSpy;
+ beforeEach(() => {
+ eventSpy = vi.fn();
+ ageSpy = vi.fn();
+ isRetiredSpy = vi.fn();
+ requestedOutreachSpy = vi.fn();
+ engine = engineFactory();
+
+ const sendRule = ruleFactory({
+ conditions: sendConditions,
+ event: sendEvent,
+ });
+ engine.addRule(sendRule);
+
+ const outreachRule = ruleFactory({
+ conditions: outreachConditions,
+ event: outreachEvent,
+ });
+ engine.addRule(outreachRule);
+
+ engine.setCondition(name, condition);
+
+ engine.addFact("age", ageSpy);
+ engine.addFact("isRetired", isRetiredSpy);
+ engine.addFact("requestedOutreach", requestedOutreachSpy);
+ engine.on("success", eventSpy);
+ });
+
+ it("emits all events when all conditions are met", async () => {
+ ageSpy.mockReturnValue(65);
+ isRetiredSpy.mockReturnValue(true);
+ requestedOutreachSpy.mockReturnValue(true);
+ await engine.run();
+ expect(eventSpy)
+ .toHaveBeenCalledWith(sendEvent, expect.anything(), expect.anything())
+ .and.toHaveBeenCalledWith(
+ outreachEvent,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it("expands condition in rule results", async () => {
+ ageSpy.mockReturnValue(65);
+ isRetiredSpy.mockReturnValue(true);
+ requestedOutreachSpy.mockReturnValue(true);
+ const { results } = await engine.run();
+ expect(results[0]).toMatchObject({
+ conditions: {
+ all: {
+ 0: {
+ all: [
+ {
+ fact: "age",
+ operator: "greaterThanInclusive",
+ value: 60,
+ },
+ ],
+ },
+ },
+ },
+ });
+ expect(results[1]).toMatchObject({
+ conditions: {
+ all: {
+ 0: {
+ all: [
+ {
+ fact: "age",
+ operator: "greaterThanInclusive",
+ value: 60,
+ },
+ ],
+ },
+ },
+ },
+ });
+ });
+ });
+
+ describe("nested condition", () => {
+ const name1 = "over60";
+ const condition1 = {
+ all: [
+ {
+ fact: "age",
+ operator: "greaterThanInclusive",
+ value: 60,
+ },
+ ],
+ };
+
+ const name2 = "earlyRetirement";
+ const condition2 = {
+ all: [
+ { not: { condition: name1 } },
+ {
+ fact: "isRetired",
+ operator: "equal",
+ value: true,
+ },
+ ],
+ };
+
+ const outreachEvent = {
+ type: "triggerOutreach",
+ };
+
+ const outreachConditions = {
+ all: [
+ { condition: name2 },
+ {
+ fact: "requestedOutreach",
+ operator: "equal",
+ value: true,
+ },
+ ],
+ };
+
+ let eventSpy;
+ let ageSpy;
+ let isRetiredSpy;
+ let requestedOutreachSpy;
+ beforeEach(() => {
+ eventSpy = vi.fn();
+ ageSpy = vi.fn();
+ isRetiredSpy = vi.fn();
+ requestedOutreachSpy = vi.fn();
+ engine = engineFactory();
+
+ const outreachRule = ruleFactory({
+ conditions: outreachConditions,
+ event: outreachEvent,
+ });
+ engine.addRule(outreachRule);
+
+ engine.setCondition(name1, condition1);
+
+ engine.setCondition(name2, condition2);
+
+ engine.addFact("age", ageSpy);
+ engine.addFact("isRetired", isRetiredSpy);
+ engine.addFact("requestedOutreach", requestedOutreachSpy);
+ engine.on("success", eventSpy);
+ });
+
+ it("emits all events when all conditions are met", async () => {
+ ageSpy.mockReturnValue(55);
+ isRetiredSpy.mockReturnValue(true);
+ requestedOutreachSpy.mockReturnValue(true);
+ await engine.run();
+ expect(eventSpy).toHaveBeenCalledWith(
+ outreachEvent,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it("expands condition in rule results", async () => {
+ ageSpy.mockReturnValue(55);
+ isRetiredSpy.mockReturnValue(true);
+ requestedOutreachSpy.mockReturnValue(true);
+ const { results } = await engine.run();
+ expect(results[0]).toMatchObject({
+ conditions: {
+ all: {
+ 0: {
+ all: [
+ {
+ not: {
+ all: [
+ {
+ fact: "age",
+ operator: "greaterThanInclusive",
+ value: 60,
+ },
+ ],
+ },
+ },
+ {
+ fact: "isRetired",
+ operator: "equal",
+ value: true,
+ },
+ ],
+ },
+ },
+ },
+ });
+ });
+ });
+
+ describe("top-level condition reference", () => {
+ const sendEvent = {
+ type: "checkSending",
+ params: {
+ sendRetirementPayment: true,
+ },
+ };
+
+ const retiredName = "retired";
+ const retiredCondition = {
+ all: [{ fact: "isRetired", operator: "equal", value: true }],
+ };
+
+ const sendConditions = {
+ condition: retiredName,
+ };
+
+ let eventSpy;
+ beforeEach(() => {
+ eventSpy = vi.fn();
+ const sendRule = ruleFactory({
+ conditions: sendConditions,
+ event: sendEvent,
+ });
+ engine = engineFactory();
+
+ engine.addRule(sendRule);
+ engine.setCondition(retiredName, retiredCondition);
+
+ engine.addFact("isRetired", true);
+ engine.on("success", eventSpy);
+ });
+
+ it("evaluates top level conditions correctly", async () => {
+ await engine.run();
+ expect(eventSpy).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/test/engine-controls.test.js b/test/engine-controls.test.js
deleted file mode 100644
index ad9b727f..00000000
--- a/test/engine-controls.test.js
+++ /dev/null
@@ -1,65 +0,0 @@
-'use strict'
-
-import engineFactory from '../src/index'
-import sinon from 'sinon'
-
-describe('Engine: fact priority', () => {
- let engine
- let sandbox
- before(() => {
- sandbox = sinon.createSandbox()
- })
- afterEach(() => {
- sandbox.restore()
- })
- const event = { type: 'adult-human-admins' }
-
- let eventSpy
- let ageStub
- let segmentStub
-
- function setup () {
- ageStub = sandbox.stub()
- segmentStub = sandbox.stub()
- eventSpy = sandbox.stub()
- engine = engineFactory()
-
- let conditions = {
- any: [{
- fact: 'age',
- operator: 'greaterThanInclusive',
- value: 18
- }]
- }
- let rule = factories.rule({ conditions, event, priority: 100 })
- engine.addRule(rule)
-
- conditions = {
- any: [{
- fact: 'segment',
- operator: 'equal',
- value: 'human'
- }]
- }
- rule = factories.rule({ conditions, event })
- engine.addRule(rule)
-
- engine.addFact('age', ageStub, { priority: 100 })
- engine.addFact('segment', segmentStub, { priority: 50 })
- }
-
- describe('stop()', () => {
- it('stops the rules from executing', async () => {
- setup()
- ageStub.returns(20) // success
- engine.on('success', (event) => {
- eventSpy()
- engine.stop()
- })
- await engine.run()
- expect(eventSpy).to.have.been.calledOnce()
- expect(ageStub).to.have.been.calledOnce()
- expect(segmentStub).to.not.have.been.called()
- })
- })
-})
diff --git a/test/engine-controls.test.mjs b/test/engine-controls.test.mjs
new file mode 100644
index 00000000..3ef59443
--- /dev/null
+++ b/test/engine-controls.test.mjs
@@ -0,0 +1,63 @@
+import engineFactory from "../src/index.mjs";
+
+import { describe, it, expect, vi } from "vitest";
+import ruleFactory from "./support/rule-factory.mjs";
+
+describe("Engine: fact priority", () => {
+ let engine;
+
+ const event = { type: "adult-human-admins" };
+
+ let eventSpy;
+ let ageStub;
+ let segmentStub;
+
+ function setup() {
+ ageStub = vi.fn();
+ segmentStub = vi.fn();
+ eventSpy = vi.fn();
+ engine = engineFactory();
+
+ let conditions = {
+ any: [
+ {
+ fact: "age",
+ operator: "greaterThanInclusive",
+ value: 18,
+ },
+ ],
+ };
+ let rule = ruleFactory({ conditions, event, priority: 100 });
+ engine.addRule(rule);
+
+ conditions = {
+ any: [
+ {
+ fact: "segment",
+ operator: "equal",
+ value: "human",
+ },
+ ],
+ };
+ rule = ruleFactory({ conditions, event });
+ engine.addRule(rule);
+
+ engine.addFact("age", ageStub, { priority: 100 });
+ engine.addFact("segment", segmentStub, { priority: 50 });
+ }
+
+ describe("stop()", () => {
+ it("stops the rules from executing", async () => {
+ setup();
+ ageStub.mockReturnValue(20); // success
+ engine.on("success", () => {
+ eventSpy();
+ engine.stop();
+ });
+ await engine.run();
+ expect(eventSpy).toHaveBeenCalledOnce();
+ expect(ageStub).toHaveBeenCalledOnce();
+ expect(segmentStub).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/test/engine-custom-properties.test.js b/test/engine-custom-properties.test.js
deleted file mode 100644
index 086e1f16..00000000
--- a/test/engine-custom-properties.test.js
+++ /dev/null
@@ -1,65 +0,0 @@
-'use strict'
-
-import engineFactory, { Fact, Rule } from '../src/index'
-
-describe('Engine: custom properties', () => {
- let engine
- const event = { type: 'generic' }
-
- describe('all conditions', () => {
- it('preserves custom properties set on fact', () => {
- engine = engineFactory()
- const fact = new Fact('age', 12)
- fact.customId = 'uuid'
- engine.addFact(fact)
- expect(engine.facts.get('age')).to.have.property('customId')
- expect(engine.facts.get('age').customId).to.equal(fact.customId)
- })
-
- describe('conditions', () => {
- it('preserves custom properties set on boolean conditions', () => {
- engine = engineFactory()
- const conditions = {
- customId: 'uuid1',
- all: [{
- fact: 'age',
- operator: 'greaterThanInclusive',
- value: 18
- }]
- }
- const rule = factories.rule({ conditions, event })
- engine.addRule(rule)
- expect(engine.rules[0].conditions).to.have.property('customId')
- })
-
- it('preserves custom properties set on regular conditions', () => {
- engine = engineFactory()
- const conditions = {
- all: [{
- customId: 'uuid',
- fact: 'age',
- operator: 'greaterThanInclusive',
- value: 18
- }]
- }
- const rule = factories.rule({ conditions, event })
- engine.addRule(rule)
- expect(engine.rules[0].conditions.all[0]).to.have.property('customId')
- expect(engine.rules[0].conditions.all[0].customId).equal('uuid')
- })
- })
-
- it('preserves custom properties set on regular conditions', () => {
- engine = engineFactory()
- const rule = new Rule()
- const ruleProperties = factories.rule()
- rule.setPriority(ruleProperties.priority)
- .setConditions(ruleProperties.conditions)
- .setEvent(ruleProperties.event)
- rule.customId = 'uuid'
- engine.addRule(rule)
- expect(engine.rules[0]).to.have.property('customId')
- expect(engine.rules[0].customId).equal('uuid')
- })
- })
-})
diff --git a/test/engine-custom-properties.test.mjs b/test/engine-custom-properties.test.mjs
new file mode 100644
index 00000000..58d3ac07
--- /dev/null
+++ b/test/engine-custom-properties.test.mjs
@@ -0,0 +1,70 @@
+import engineFactory, { Fact, Rule } from "../src/index.mjs";
+import { describe, it, expect } from "vitest";
+import ruleFactory from "./support/rule-factory.mjs";
+
+describe("Engine: custom properties", () => {
+ let engine;
+ const event = { type: "generic" };
+
+ describe("all conditions", () => {
+ it("preserves custom properties set on fact", () => {
+ engine = engineFactory();
+ const fact = new Fact("age", 12);
+ fact.customId = "uuid";
+ engine.addFact(fact);
+ expect(engine.facts.get("age")).toHaveProperty("customId");
+ expect(engine.facts.get("age").customId).toBe(fact.customId);
+ });
+
+ describe("conditions", () => {
+ it("preserves custom properties set on boolean conditions", () => {
+ engine = engineFactory();
+ const conditions = {
+ customId: "uuid1",
+ all: [
+ {
+ fact: "age",
+ operator: "greaterThanInclusive",
+ value: 18,
+ },
+ ],
+ };
+ const rule = ruleFactory({ conditions, event });
+ engine.addRule(rule);
+ expect(engine.rules[0].conditions).toHaveProperty("customId");
+ });
+
+ it("preserves custom properties set on regular conditions", () => {
+ engine = engineFactory();
+ const conditions = {
+ all: [
+ {
+ customId: "uuid",
+ fact: "age",
+ operator: "greaterThanInclusive",
+ value: 18,
+ },
+ ],
+ };
+ const rule = ruleFactory({ conditions, event });
+ engine.addRule(rule);
+ expect(engine.rules[0].conditions.all[0]).toHaveProperty("customId");
+ expect(engine.rules[0].conditions.all[0].customId).equal("uuid");
+ });
+ });
+
+ it("preserves custom properties set on regular conditions", () => {
+ engine = engineFactory();
+ const rule = new Rule();
+ const ruleProperties = ruleFactory();
+ rule
+ .setPriority(ruleProperties.priority)
+ .setConditions(ruleProperties.conditions)
+ .setEvent(ruleProperties.event);
+ rule.customId = "uuid";
+ engine.addRule(rule);
+ expect(engine.rules[0]).toHaveProperty("customId");
+ expect(engine.rules[0].customId).equal("uuid");
+ });
+ });
+});
diff --git a/test/engine-error-handling.test.js b/test/engine-error-handling.test.js
deleted file mode 100644
index 4e85795b..00000000
--- a/test/engine-error-handling.test.js
+++ /dev/null
@@ -1,28 +0,0 @@
-'use strict'
-
-import engineFactory from '../src/index'
-
-describe('Engine: failure', () => {
- let engine
-
- const event = { type: 'generic' }
- const conditions = {
- any: [{
- fact: 'age',
- operator: 'greaterThanInclusive',
- value: 21
- }]
- }
- beforeEach(() => {
- engine = engineFactory()
- const determineDrinkingAgeRule = factories.rule({ conditions, event })
- engine.addRule(determineDrinkingAgeRule)
- engine.addFact('age', function (params, engine) {
- throw new Error('problem occurred')
- })
- })
-
- it('surfaces errors', () => {
- return expect(engine.run()).to.eventually.rejectedWith(/problem occurred/)
- })
-})
diff --git a/test/engine-error-handling.test.mjs b/test/engine-error-handling.test.mjs
new file mode 100644
index 00000000..ccb1ac64
--- /dev/null
+++ b/test/engine-error-handling.test.mjs
@@ -0,0 +1,30 @@
+import engineFactory from "../src/index.mjs";
+import { describe, it, beforeEach, expect } from "vitest";
+import ruleFactory from "./support/rule-factory.mjs";
+
+describe("Engine: failure", () => {
+ let engine;
+
+ const event = { type: "generic" };
+ const conditions = {
+ any: [
+ {
+ fact: "age",
+ operator: "greaterThanInclusive",
+ value: 21,
+ },
+ ],
+ };
+ beforeEach(() => {
+ engine = engineFactory();
+ const determineDrinkingAgeRule = ruleFactory({ conditions, event });
+ engine.addRule(determineDrinkingAgeRule);
+ engine.addFact("age", function () {
+ throw new Error("problem occurred");
+ });
+ });
+
+ it("surfaces errors", () => {
+ return expect(engine.run()).rejects.toThrow(/problem occurred/);
+ });
+});
diff --git a/test/engine-event.test.js b/test/engine-event.test.js
deleted file mode 100644
index c929d92e..00000000
--- a/test/engine-event.test.js
+++ /dev/null
@@ -1,605 +0,0 @@
-'use strict'
-
-import engineFactory, { Fact } from '../src/index'
-import Almanac from '../src/almanac'
-import sinon from 'sinon'
-
-describe('Engine: event', () => {
- let engine
- let sandbox
- before(() => {
- sandbox = sinon.createSandbox()
- })
- afterEach(() => {
- sandbox.restore()
- })
-
- const event = {
- type: 'setDrinkingFlag',
- params: {
- canOrderDrinks: true
- }
- }
- /**
- * sets up a simple 'any' rule with 2 conditions
- */
- function simpleSetup () {
- const conditions = {
- any: [
- {
- name: 'over 21',
- fact: 'age',
- operator: 'greaterThanInclusive',
- value: 21
- },
- {
- fact: 'qualified',
- operator: 'equal',
- value: true
- }
- ]
- }
- engine = engineFactory()
- const ruleOptions = { conditions, event, priority: 100 }
- const determineDrinkingAgeRule = factories.rule(ruleOptions)
- engine.addRule(determineDrinkingAgeRule)
- // age will succeed because 21 >= 21
- engine.addFact('age', 21)
- // set 'qualified' to fail. rule will succeed because of 'any'
- engine.addFact('qualified', false)
- }
-
- /**
- * sets up a complex rule with nested conditions
- */
- function advancedSetup () {
- const conditions = {
- any: [
- {
- fact: 'age',
- operator: 'greaterThanInclusive',
- value: 21
- },
- {
- fact: 'qualified',
- operator: 'equal',
- value: true
- },
- {
- all: [
- {
- fact: 'zipCode',
- operator: 'in',
- value: [80211, 80403]
- },
- {
- fact: 'gender',
- operator: 'notEqual',
- value: 'female'
- }
- ]
- }
- ]
- }
- engine = engineFactory()
- const ruleOptions = { conditions, event, priority: 100 }
- const determineDrinkingAgeRule = factories.rule(ruleOptions)
- engine.addRule(determineDrinkingAgeRule)
- // rule will succeed because of 'any'
- engine.addFact('age', 10) // age fails
- engine.addFact('qualified', false) // qualified fails.
- engine.addFact('zipCode', 80403) // zipCode succeeds
- engine.addFact('gender', 'male') // gender succeeds
- }
-
- context('engine events: simple', () => {
- beforeEach(() => simpleSetup())
-
- it('"success" passes the event, almanac, and results', async () => {
- const failureSpy = sandbox.spy()
- const successSpy = sandbox.spy()
- function assertResult (ruleResult) {
- expect(ruleResult.result).to.be.true()
- expect(ruleResult.conditions.any[0].result).to.be.true()
- expect(ruleResult.conditions.any[0].factResult).to.equal(21)
- expect(ruleResult.conditions.any[0].name).to.equal('over 21')
- expect(ruleResult.conditions.any[1].result).to.be.false()
- expect(ruleResult.conditions.any[1].factResult).to.equal(false)
- }
- engine.on('success', function (e, almanac, ruleResult) {
- expect(e).to.eql(event)
- expect(almanac).to.be.an.instanceof(Almanac)
- assertResult(ruleResult)
- successSpy()
- })
- engine.on('failure', failureSpy)
-
- const { results, failureResults } = await engine.run()
-
- expect(failureResults).to.have.lengthOf(0)
- expect(results).to.have.lengthOf(1)
- assertResult(results[0])
- expect(failureSpy.callCount).to.equal(0)
- expect(successSpy.callCount).to.equal(1)
- })
-
- it('"event.type" passes the event parameters, almanac, and results', async () => {
- const failureSpy = sandbox.spy()
- const successSpy = sandbox.spy()
- function assertResult (ruleResult) {
- expect(ruleResult.result).to.be.true()
- expect(ruleResult.conditions.any[0].result).to.be.true()
- expect(ruleResult.conditions.any[0].factResult).to.equal(21)
- expect(ruleResult.conditions.any[1].result).to.be.false()
- expect(ruleResult.conditions.any[1].factResult).to.equal(false)
- }
- engine.on(event.type, function (params, almanac, ruleResult) {
- expect(params).to.eql(event.params)
- expect(almanac).to.be.an.instanceof(Almanac)
- assertResult(ruleResult)
- successSpy()
- })
- engine.on('failure', failureSpy)
-
- const { results, failureResults } = await engine.run()
-
- expect(failureResults).to.have.lengthOf(0)
- expect(results).to.have.lengthOf(1)
- assertResult(results[0])
-
- expect(failureSpy.callCount).to.equal(0)
- expect(successSpy.callCount).to.equal(1)
- })
-
- it('"failure" passes the event, almanac, and results', async () => {
- const AGE = 10
- const failureSpy = sandbox.spy()
- const successSpy = sandbox.spy()
- function assertResult (ruleResult) {
- expect(ruleResult.result).to.be.false()
- expect(ruleResult.conditions.any[0].result).to.be.false()
- expect(ruleResult.conditions.any[0].factResult).to.equal(AGE)
- expect(ruleResult.conditions.any[1].result).to.be.false()
- expect(ruleResult.conditions.any[1].factResult).to.equal(false)
- }
-
- engine.on('failure', function (e, almanac, ruleResult) {
- expect(e).to.eql(event)
- expect(almanac).to.be.an.instanceof(Almanac)
- assertResult(ruleResult)
- failureSpy()
- })
- engine.on('success', successSpy)
- engine.addFact('age', AGE) // age fails
-
- const { results, failureResults } = await engine.run()
-
- expect(failureResults).to.have.lengthOf(1)
- expect(results).to.have.lengthOf(0)
- assertResult(failureResults[0])
-
- expect(failureSpy.callCount).to.equal(1)
- expect(successSpy.callCount).to.equal(0)
- })
-
- it('allows facts to be added by the event handler, affecting subsequent rules', () => {
- const drinkOrderParams = { wine: 'merlot', quantity: 2 }
- const drinkOrderEvent = {
- type: 'offerDrink',
- params: drinkOrderParams
- }
- const drinkOrderConditions = {
- any: [
- {
- fact: 'canOrderDrinks',
- operator: 'equal',
- value: true
- }
- ]
- }
- const drinkOrderRule = factories.rule({
- conditions: drinkOrderConditions,
- event: drinkOrderEvent,
- priority: 1
- })
- engine.addRule(drinkOrderRule)
- return new Promise((resolve, reject) => {
- engine.on('success', function (event, almanac, ruleResult) {
- switch (event.type) {
- case 'setDrinkingFlag':
- almanac.addRuntimeFact(
- 'canOrderDrinks',
- event.params.canOrderDrinks
- )
- break
- case 'offerDrink':
- expect(event.params).to.eql(drinkOrderParams)
- break
- default:
- reject(new Error('default case not expected'))
- }
- })
- engine.run().then(resolve).catch(reject)
- })
- })
- })
-
- context('engine events: advanced', () => {
- beforeEach(() => advancedSetup())
-
- it('"success" passes the event, almanac, and results', async () => {
- const failureSpy = sandbox.spy()
- const successSpy = sandbox.spy()
-
- function assertResult (ruleResult) {
- expect(ruleResult.result).to.be.true()
- expect(ruleResult.conditions.any[0].result).to.be.false()
- expect(ruleResult.conditions.any[0].factResult).to.equal(10)
- expect(ruleResult.conditions.any[1].result).to.be.false()
- expect(ruleResult.conditions.any[1].factResult).to.equal(false)
- expect(ruleResult.conditions.any[2].result).to.be.true()
- expect(ruleResult.conditions.any[2].all[0].result).to.be.true()
- expect(ruleResult.conditions.any[2].all[0].factResult).to.equal(80403)
- expect(ruleResult.conditions.any[2].all[1].result).to.be.true()
- expect(ruleResult.conditions.any[2].all[1].factResult).to.equal('male')
- }
-
- engine.on('success', function (e, almanac, ruleResult) {
- expect(e).to.eql(event)
- expect(almanac).to.be.an.instanceof(Almanac)
- assertResult(ruleResult)
- successSpy()
- })
- engine.on('failure', failureSpy)
-
- const { results, failureResults } = await engine.run()
-
- assertResult(results[0])
- expect(failureResults).to.have.lengthOf(0)
- expect(results).to.have.lengthOf(1)
- expect(failureSpy.callCount).to.equal(0)
- expect(successSpy.callCount).to.equal(1)
- })
-
- it('"failure" passes the event, almanac, and results', async () => {
- const ZIP_CODE = 99992
- const GENDER = 'female'
- const failureSpy = sandbox.spy()
- const successSpy = sandbox.spy()
- function assertResult (ruleResult) {
- expect(ruleResult.result).to.be.false()
- expect(ruleResult.conditions.any[0].result).to.be.false()
- expect(ruleResult.conditions.any[0].factResult).to.equal(10)
- expect(ruleResult.conditions.any[1].result).to.be.false()
- expect(ruleResult.conditions.any[1].factResult).to.equal(false)
- expect(ruleResult.conditions.any[2].result).to.be.false()
- expect(ruleResult.conditions.any[2].all[0].result).to.be.false()
- expect(ruleResult.conditions.any[2].all[0].factResult).to.equal(
- ZIP_CODE
- )
- expect(ruleResult.conditions.any[2].all[1].result).to.be.false()
- expect(ruleResult.conditions.any[2].all[1].factResult).to.equal(GENDER)
- }
- engine.on('failure', function (e, almanac, ruleResult) {
- expect(e).to.eql(event)
- expect(almanac).to.be.an.instanceof(Almanac)
- assertResult(ruleResult)
- failureSpy()
- })
- engine.on('success', successSpy)
- engine.addFact('zipCode', ZIP_CODE) // zipCode fails
- engine.addFact('gender', GENDER) // gender fails
-
- const { results, failureResults } = await engine.run()
-
- assertResult(failureResults[0])
- expect(failureResults).to.have.lengthOf(1)
- expect(results).to.have.lengthOf(0)
-
- expect(failureSpy.callCount).to.equal(1)
- expect(successSpy.callCount).to.equal(0)
- })
- })
-
- context('engine events: with facts', () => {
- const eventWithFact = {
- type: 'countedEnough',
- params: {
- count: { fact: 'count' }
- }
- }
-
- const expectedEvent = { type: 'countedEnough', params: { count: 5 } }
-
- function setup (replaceFactsInEventParams, event = eventWithFact) {
- const conditions = {
- any: [
- {
- fact: 'success',
- operator: 'equal',
- value: true
- }
- ]
- }
-
- const ruleOptions = { conditions, event, priority: 100 }
- const countedEnoughRule = factories.rule(ruleOptions)
- engine = engineFactory([countedEnoughRule], {
- replaceFactsInEventParams
- })
- }
- context('without flag', () => {
- beforeEach(() => setup(false))
- it('"success" passes the event without resolved facts', async () => {
- const successSpy = sandbox.spy()
- engine.on('success', successSpy)
- const { results } = await engine.run({ success: true, count: 5 })
- expect(results[0].event).to.deep.equal(eventWithFact)
- expect(successSpy.firstCall.args[0]).to.deep.equal(eventWithFact)
- })
-
- it('failure passes the event without resolved facts', async () => {
- const failureSpy = sandbox.spy()
- engine.on('failure', failureSpy)
- const { failureResults } = await engine.run({ success: false, count: 5 })
- expect(failureResults[0].event).to.deep.equal(eventWithFact)
- expect(failureSpy.firstCall.args[0]).to.deep.equal(eventWithFact)
- })
- })
- context('with flag', () => {
- beforeEach(() => setup(true))
- it('"success" passes the event with resolved facts', async () => {
- const successSpy = sandbox.spy()
- engine.on('success', successSpy)
- const { results } = await engine.run({ success: true, count: 5 })
- expect(results[0].event).to.deep.equal(expectedEvent)
- expect(successSpy.firstCall.args[0]).to.deep.equal(expectedEvent)
- })
-
- it('failure passes the event with resolved facts', async () => {
- const failureSpy = sandbox.spy()
- engine.on('failure', failureSpy)
- const { failureResults } = await engine.run({ success: false, count: 5 })
- expect(failureResults[0].event).to.deep.equal(expectedEvent)
- expect(failureSpy.firstCall.args[0]).to.deep.equal(expectedEvent)
- })
- context('using fact params and path', () => {
- const eventWithFactWithParamsAndPath = {
- type: 'countedEnough',
- params: {
- count: {
- fact: 'count',
- params: { incrementBy: 5 },
- path: '$.next'
- }
- }
- }
-
- beforeEach(() => {
- setup(true, eventWithFactWithParamsAndPath)
- engine.addFact(
- new Fact('count', async ({ incrementBy }) => {
- return {
- previous: 0,
- next: incrementBy
- }
- })
- )
- })
- it('"success" passes the event with resolved facts', async () => {
- const successSpy = sandbox.spy()
- engine.on('success', successSpy)
- const { results } = await engine.run({ success: true })
- expect(results[0].event).to.deep.equal(expectedEvent)
- expect(successSpy.firstCall.args[0]).to.deep.equal(expectedEvent)
- })
-
- it('failure passes the event with resolved facts', async () => {
- const failureSpy = sandbox.spy()
- engine.on('failure', failureSpy)
- const { failureResults } = await engine.run({ success: false })
- expect(failureResults[0].event).to.deep.equal(expectedEvent)
- expect(failureSpy.firstCall.args[0]).to.deep.equal(expectedEvent)
- })
- })
- })
- })
-
- context('rule events: simple', () => {
- beforeEach(() => simpleSetup())
-
- it('the rule result is a _copy_ of the rule`s conditions, and unaffected by mutation', async () => {
- const rule = engine.rules[0]
- let firstPass
- rule.on('success', function (e, almanac, ruleResult) {
- firstPass = ruleResult
- delete ruleResult.conditions.any // subsequently modify the conditions in this rule result
- })
- await engine.run()
-
- // run the engine again, now that ruleResult.conditions was modified
- let secondPass
- rule.on('success', function (e, almanac, ruleResult) {
- secondPass = ruleResult
- })
- await engine.run()
-
- expect(firstPass).to.deep.equal(secondPass) // second pass was unaffected by first pass
- })
-
- it('on-success, it passes the event type and params', async () => {
- const failureSpy = sandbox.spy()
- const successSpy = sandbox.spy()
- const rule = engine.rules[0]
- function assertResult (ruleResult) {
- expect(ruleResult.result).to.be.true()
- expect(ruleResult.conditions.any[0].result).to.be.true()
- expect(ruleResult.conditions.any[0].factResult).to.equal(21)
- expect(ruleResult.conditions.any[1].result).to.be.false()
- expect(ruleResult.conditions.any[1].factResult).to.equal(false)
- }
-
- rule.on('success', function (e, almanac, ruleResult) {
- expect(e).to.eql(event)
- expect(almanac).to.be.an.instanceof(Almanac)
- expect(failureSpy.callCount).to.equal(0)
- assertResult(ruleResult)
- successSpy()
- })
- rule.on('failure', failureSpy)
-
- const { results, failureResults } = await engine.run()
-
- assertResult(results[0])
- expect(failureResults).to.have.lengthOf(0)
- expect(results).to.have.lengthOf(1)
-
- expect(successSpy.callCount).to.equal(1)
- expect(failureSpy.callCount).to.equal(0)
- })
-
- it('on-failure, it passes the event type and params', async () => {
- const AGE = 10
- const successSpy = sandbox.spy()
- const failureSpy = sandbox.spy()
- const rule = engine.rules[0]
- function assertResult (ruleResult) {
- expect(ruleResult.result).to.be.false()
- expect(ruleResult.conditions.any[0].result).to.be.false()
- expect(ruleResult.conditions.any[0].factResult).to.equal(AGE)
- expect(ruleResult.conditions.any[1].result).to.be.false()
- expect(ruleResult.conditions.any[1].factResult).to.equal(false)
- }
- rule.on('failure', function (e, almanac, ruleResult) {
- expect(e).to.eql(event)
- expect(almanac).to.be.an.instanceof(Almanac)
- expect(successSpy.callCount).to.equal(0)
- assertResult(ruleResult)
- failureSpy()
- })
- rule.on('success', successSpy)
- // both conditions will fail
- engine.addFact('age', AGE)
- const { results, failureResults } = await engine.run()
-
- assertResult(failureResults[0])
- expect(failureResults).to.have.lengthOf(1)
- expect(results).to.have.lengthOf(0)
- expect(failureSpy.callCount).to.equal(1)
- expect(successSpy.callCount).to.equal(0)
- })
- })
-
- context('rule events: with facts', () => {
- const expectedEvent = { type: 'countedEnough', params: { count: 5 } }
- const eventWithFact = {
- type: 'countedEnough',
- params: {
- count: { fact: 'count' }
- }
- }
-
- function setup (replaceFactsInEventParams, event = eventWithFact) {
- const conditions = {
- any: [
- {
- fact: 'success',
- operator: 'equal',
- value: true
- }
- ]
- }
-
- const ruleOptions = { conditions, event, priority: 100 }
- const countedEnoughRule = factories.rule(ruleOptions)
- engine = engineFactory([countedEnoughRule], {
- replaceFactsInEventParams
- })
- }
- context('without flag', () => {
- beforeEach(() => setup(false))
- it('"success" passes the event without resolved facts', async () => {
- const successSpy = sandbox.spy()
- engine.rules[0].on('success', successSpy)
- await engine.run({ success: true, count: 5 })
- expect(successSpy.firstCall.args[0]).to.deep.equal(eventWithFact)
- })
-
- it('failure passes the event without resolved facts', async () => {
- const failureSpy = sandbox.spy()
- engine.rules[0].on('failure', failureSpy)
- await engine.run({ success: false, count: 5 })
- expect(failureSpy.firstCall.args[0]).to.deep.equal(eventWithFact)
- })
- })
- context('with flag', () => {
- beforeEach(() => setup(true))
- it('"success" passes the event with resolved facts', async () => {
- const successSpy = sandbox.spy()
- engine.rules[0].on('success', successSpy)
- await engine.run({ success: true, count: 5 })
- expect(successSpy.firstCall.args[0]).to.deep.equal(expectedEvent)
- })
-
- it('failure passes the event with resolved facts', async () => {
- const failureSpy = sandbox.spy()
- engine.rules[0].on('failure', failureSpy)
- await engine.run({ success: false, count: 5 })
- expect(failureSpy.firstCall.args[0]).to.deep.equal(expectedEvent)
- })
- context('using fact params and path', () => {
- const eventWithFactWithParamsAndPath = {
- type: 'countedEnough',
- params: {
- count: {
- fact: 'count',
- params: { incrementBy: 5 },
- path: '$.next'
- }
- }
- }
-
- beforeEach(() => {
- setup(true, eventWithFactWithParamsAndPath)
- engine.addFact(
- new Fact('count', async ({ incrementBy }) => {
- return {
- previous: 0,
- next: incrementBy
- }
- })
- )
- })
- it('"success" passes the event with resolved facts', async () => {
- const successSpy = sandbox.spy()
- engine.on('success', successSpy)
- const { results } = await engine.run({ success: true })
- expect(results[0].event).to.deep.equal(expectedEvent)
- expect(successSpy.firstCall.args[0]).to.deep.equal(expectedEvent)
- })
-
- it('failure passes the event with resolved facts', async () => {
- const failureSpy = sandbox.spy()
- engine.on('failure', failureSpy)
- const { failureResults } = await engine.run({ success: false })
- expect(failureResults[0].event).to.deep.equal(expectedEvent)
- expect(failureSpy.firstCall.args[0]).to.deep.equal(expectedEvent)
- })
- })
- })
- })
-
- context('rule events: json serializing', () => {
- beforeEach(() => simpleSetup())
- it('serializes properties', async () => {
- const successSpy = sandbox.spy()
- const rule = engine.rules[0]
- rule.on('success', successSpy)
- await engine.run()
- const ruleResult = successSpy.getCall(0).args[2]
- const expected =
- '{"conditions":{"priority":1,"any":[{"name":"over 21","operator":"greaterThanInclusive","value":21,"fact":"age","factResult":21,"result":true},{"operator":"equal","value":true,"fact":"qualified","factResult":false,"result":false}]},"event":{"type":"setDrinkingFlag","params":{"canOrderDrinks":true}},"priority":100,"result":true}'
- expect(JSON.stringify(ruleResult)).to.equal(expected)
- })
- })
-})
diff --git a/test/engine-event.test.mjs b/test/engine-event.test.mjs
new file mode 100644
index 00000000..11bae3eb
--- /dev/null
+++ b/test/engine-event.test.mjs
@@ -0,0 +1,650 @@
+import Almanac from "../src/almanac.mjs";
+import engineFactory, { Fact } from "../src/index.mjs";
+
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import ruleFactory from "./support/rule-factory.mjs";
+
+describe("Engine: event", () => {
+ let engine;
+
+ const event = {
+ type: "setDrinkingFlag",
+ params: {
+ canOrderDrinks: true,
+ },
+ };
+ /**
+ * sets up a simple 'any' rule with 2 conditions
+ */
+ function simpleSetup() {
+ const conditions = {
+ any: [
+ {
+ name: "over 21",
+ fact: "age",
+ operator: "greaterThanInclusive",
+ value: 21,
+ },
+ {
+ fact: "qualified",
+ operator: "equal",
+ value: true,
+ },
+ ],
+ };
+ engine = engineFactory();
+ const ruleOptions = { conditions, event, priority: 100 };
+ const determineDrinkingAgeRule = ruleFactory(ruleOptions);
+ engine.addRule(determineDrinkingAgeRule);
+ // age will succeed because 21 >= 21
+ engine.addFact("age", 21);
+ // set 'qualified' to fail. rule will succeed because of 'any'
+ engine.addFact("qualified", false);
+ }
+
+ /**
+ * sets up a complex rule with nested conditions
+ */
+ function advancedSetup() {
+ const conditions = {
+ any: [
+ {
+ fact: "age",
+ operator: "greaterThanInclusive",
+ value: 21,
+ },
+ {
+ fact: "qualified",
+ operator: "equal",
+ value: true,
+ },
+ {
+ all: [
+ {
+ fact: "zipCode",
+ operator: "in",
+ value: [80211, 80403],
+ },
+ {
+ fact: "gender",
+ operator: "notEqual",
+ value: "female",
+ },
+ ],
+ },
+ ],
+ };
+ engine = engineFactory();
+ const ruleOptions = { conditions, event, priority: 100 };
+ const determineDrinkingAgeRule = ruleFactory(ruleOptions);
+ engine.addRule(determineDrinkingAgeRule);
+ // rule will succeed because of 'any'
+ engine.addFact("age", 10); // age fails
+ engine.addFact("qualified", false); // qualified fails.
+ engine.addFact("zipCode", 80403); // zipCode succeeds
+ engine.addFact("gender", "male"); // gender succeeds
+ }
+
+ describe("engine events: simple", () => {
+ beforeEach(() => simpleSetup());
+
+ it('"success" passes the event, almanac, and results', async () => {
+ const failureSpy = vi.fn();
+ const successSpy = vi.fn();
+ function assertResult(ruleResult) {
+ expect(ruleResult.result).toBe(true);
+ expect(ruleResult.conditions.any[0].result).toBe(true);
+ expect(ruleResult.conditions.any[0].factResult).toBe(21);
+ expect(ruleResult.conditions.any[0].name).toBe("over 21");
+ expect(ruleResult.conditions.any[1].result).toBe(false);
+ expect(ruleResult.conditions.any[1].factResult).toBe(false);
+ }
+ engine.on("success", function (e, almanac, ruleResult) {
+ expect(e).toEqual(event);
+ expect(almanac).toBeInstanceOf(Almanac);
+ assertResult(ruleResult);
+ successSpy();
+ });
+ engine.on("failure", failureSpy);
+
+ const { results, failureResults } = await engine.run();
+
+ expect(failureResults).toHaveLength(0);
+ expect(results).toHaveLength(1);
+ assertResult(results[0]);
+ expect(failureSpy).not.toHaveBeenCalled();
+ expect(successSpy).toHaveBeenCalledOnce();
+ });
+
+ it('"event.type" passes the event parameters, almanac, and results', async () => {
+ const failureSpy = vi.fn();
+ const successSpy = vi.fn();
+ function assertResult(ruleResult) {
+ expect(ruleResult.result).toBe(true);
+ expect(ruleResult.conditions.any[0].result).toBe(true);
+ expect(ruleResult.conditions.any[0].factResult).toBe(21);
+ expect(ruleResult.conditions.any[1].result).toBe(false);
+ expect(ruleResult.conditions.any[1].factResult).toBe(false);
+ }
+ engine.on(event.type, function (params, almanac, ruleResult) {
+ expect(params).toEqual(event.params);
+ expect(almanac).toBeInstanceOf(Almanac);
+ assertResult(ruleResult);
+ successSpy();
+ });
+ engine.on("failure", failureSpy);
+
+ const { results, failureResults } = await engine.run();
+
+ expect(failureResults).toHaveLength(0);
+ expect(results).toHaveLength(1);
+ assertResult(results[0]);
+
+ expect(failureSpy).not.toHaveBeenCalled();
+ expect(successSpy).toHaveBeenCalledOnce();
+ });
+
+ it('"failure" passes the event, almanac, and results', async () => {
+ const AGE = 10;
+ const failureSpy = vi.fn();
+ const successSpy = vi.fn();
+ function assertResult(ruleResult) {
+ expect(ruleResult.result).toBe(false);
+ expect(ruleResult.conditions.any[0].result).toBe(false);
+ expect(ruleResult.conditions.any[0].factResult).toBe(AGE);
+ expect(ruleResult.conditions.any[1].result).toBe(false);
+ expect(ruleResult.conditions.any[1].factResult).toBe(false);
+ }
+
+ engine.on("failure", function (e, almanac, ruleResult) {
+ expect(e).toEqual(event);
+ expect(almanac).toBeInstanceOf(Almanac);
+ assertResult(ruleResult);
+ failureSpy();
+ });
+ engine.on("success", successSpy);
+ engine.addFact("age", AGE); // age fails
+
+ const { results, failureResults } = await engine.run();
+
+ expect(failureResults).toHaveLength(1);
+ expect(results).toHaveLength(0);
+ assertResult(failureResults[0]);
+
+ expect(failureSpy).toHaveBeenCalledOnce();
+ expect(successSpy).not.toHaveBeenCalled();
+ });
+
+ it("allows facts to be added by the event handler, affecting subsequent rules", () => {
+ const drinkOrderParams = { wine: "merlot", quantity: 2 };
+ const drinkOrderEvent = {
+ type: "offerDrink",
+ params: drinkOrderParams,
+ };
+ const drinkOrderConditions = {
+ any: [
+ {
+ fact: "canOrderDrinks",
+ operator: "equal",
+ value: true,
+ },
+ ],
+ };
+ const drinkOrderRule = ruleFactory({
+ conditions: drinkOrderConditions,
+ event: drinkOrderEvent,
+ priority: 1,
+ });
+ engine.addRule(drinkOrderRule);
+ return new Promise((resolve, reject) => {
+ engine.on("success", function (event, almanac) {
+ switch (event.type) {
+ case "setDrinkingFlag":
+ almanac.addRuntimeFact(
+ "canOrderDrinks",
+ event.params.canOrderDrinks,
+ );
+ break;
+ case "offerDrink":
+ expect(event.params).toEqual(drinkOrderParams);
+ break;
+ default:
+ reject(new Error("default case not expected"));
+ }
+ });
+ engine.run().then(resolve).catch(reject);
+ });
+ });
+ });
+
+ describe("engine events: advanced", () => {
+ beforeEach(() => advancedSetup());
+
+ it('"success" passes the event, almanac, and results', async () => {
+ const failureSpy = vi.fn();
+ const successSpy = vi.fn();
+
+ function assertResult(ruleResult) {
+ expect(ruleResult.result).toBe(true);
+ expect(ruleResult.conditions.any[0].result).toBe(false);
+ expect(ruleResult.conditions.any[0].factResult).toBe(10);
+ expect(ruleResult.conditions.any[1].result).toBe(false);
+ expect(ruleResult.conditions.any[1].factResult).toBe(false);
+ expect(ruleResult.conditions.any[2].result).toBe(true);
+ expect(ruleResult.conditions.any[2].all[0].result).toBe(true);
+ expect(ruleResult.conditions.any[2].all[0].factResult).toBe(80403);
+ expect(ruleResult.conditions.any[2].all[1].result).toBe(true);
+ expect(ruleResult.conditions.any[2].all[1].factResult).toBe("male");
+ }
+
+ engine.on("success", function (e, almanac, ruleResult) {
+ expect(e).toEqual(event);
+ expect(almanac).toBeInstanceOf(Almanac);
+ assertResult(ruleResult);
+ successSpy();
+ });
+ engine.on("failure", failureSpy);
+
+ const { results, failureResults } = await engine.run();
+
+ assertResult(results[0]);
+ expect(failureResults).toHaveLength(0);
+ expect(results).toHaveLength(1);
+ expect(failureSpy).not.toHaveBeenCalled();
+ expect(successSpy).toHaveBeenCalledOnce();
+ });
+
+ it('"failure" passes the event, almanac, and results', async () => {
+ const ZIP_CODE = 99992;
+ const GENDER = "female";
+ const failureSpy = vi.fn();
+ const successSpy = vi.fn();
+ function assertResult(ruleResult) {
+ expect(ruleResult.result).toBe(false);
+ expect(ruleResult.conditions.any[0].result).toBe(false);
+ expect(ruleResult.conditions.any[0].factResult).toBe(10);
+ expect(ruleResult.conditions.any[1].result).toBe(false);
+ expect(ruleResult.conditions.any[1].factResult).toBe(false);
+ expect(ruleResult.conditions.any[2].result).toBe(false);
+ expect(ruleResult.conditions.any[2].all[0].result).toBe(false);
+ expect(ruleResult.conditions.any[2].all[0].factResult).toBe(ZIP_CODE);
+ expect(ruleResult.conditions.any[2].all[1].result).toBe(false);
+ expect(ruleResult.conditions.any[2].all[1].factResult).toBe(GENDER);
+ }
+ engine.on("failure", function (e, almanac, ruleResult) {
+ expect(e).toEqual(event);
+ expect(almanac).toBeInstanceOf(Almanac);
+ assertResult(ruleResult);
+ failureSpy();
+ });
+ engine.on("success", successSpy);
+ engine.addFact("zipCode", ZIP_CODE); // zipCode fails
+ engine.addFact("gender", GENDER); // gender fails
+
+ const { results, failureResults } = await engine.run();
+
+ assertResult(failureResults[0]);
+ expect(failureResults).toHaveLength(1);
+ expect(results).toHaveLength(0);
+
+ expect(failureSpy).toHaveBeenCalledOnce();
+ expect(successSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("engine events: with facts", () => {
+ const eventWithFact = {
+ type: "countedEnough",
+ params: {
+ count: { fact: "count" },
+ },
+ };
+
+ const expectedEvent = { type: "countedEnough", params: { count: 5 } };
+
+ function setup(replaceFactsInEventParams, event = eventWithFact) {
+ const conditions = {
+ any: [
+ {
+ fact: "success",
+ operator: "equal",
+ value: true,
+ },
+ ],
+ };
+
+ const ruleOptions = { conditions, event, priority: 100 };
+ const countedEnoughRule = ruleFactory(ruleOptions);
+ engine = engineFactory([countedEnoughRule], {
+ replaceFactsInEventParams,
+ });
+ }
+ describe("without flag", () => {
+ beforeEach(() => setup(false));
+ it('"success" passes the event without resolved facts', async () => {
+ const successSpy = vi.fn();
+ engine.on("success", successSpy);
+ const { results } = await engine.run({ success: true, count: 5 });
+ expect(results[0].event).toEqual(eventWithFact);
+ expect(successSpy).toHaveBeenCalledWith(
+ eventWithFact,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it("failure passes the event without resolved facts", async () => {
+ const failureSpy = vi.fn();
+ engine.on("failure", failureSpy);
+ const { failureResults } = await engine.run({
+ success: false,
+ count: 5,
+ });
+ expect(failureResults[0].event).toEqual(eventWithFact);
+ expect(failureSpy).toHaveBeenCalledWith(
+ eventWithFact,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+ });
+ describe("with flag", () => {
+ beforeEach(() => setup(true));
+ it('"success" passes the event with resolved facts', async () => {
+ const successSpy = vi.fn();
+ engine.on("success", successSpy);
+ const { results } = await engine.run({ success: true, count: 5 });
+ expect(results[0].event).toEqual(expectedEvent);
+ expect(successSpy).toHaveBeenCalledWith(
+ expectedEvent,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it("failure passes the event with resolved facts", async () => {
+ const failureSpy = vi.fn();
+ engine.on("failure", failureSpy);
+ const { failureResults } = await engine.run({
+ success: false,
+ count: 5,
+ });
+ expect(failureResults[0].event).toEqual(expectedEvent);
+ expect(failureSpy).toHaveBeenCalledWith(
+ expectedEvent,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+ describe("using fact params and path", () => {
+ const eventWithFactWithParamsAndPath = {
+ type: "countedEnough",
+ params: {
+ count: {
+ fact: "count",
+ params: { incrementBy: 5 },
+ path: "$.next",
+ },
+ },
+ };
+
+ beforeEach(() => {
+ setup(true, eventWithFactWithParamsAndPath);
+ engine.addFact(
+ new Fact("count", async ({ incrementBy }) => {
+ return {
+ previous: 0,
+ next: incrementBy,
+ };
+ }),
+ );
+ });
+ it('"success" passes the event with resolved facts', async () => {
+ const successSpy = vi.fn();
+ engine.on("success", successSpy);
+ const { results } = await engine.run({ success: true });
+ expect(results[0].event).toEqual(expectedEvent);
+ expect(successSpy).toHaveBeenCalledWith(
+ expectedEvent,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it("failure passes the event with resolved facts", async () => {
+ const failureSpy = vi.fn();
+ engine.on("failure", failureSpy);
+ const { failureResults } = await engine.run({ success: false });
+ expect(failureResults[0].event).toEqual(expectedEvent);
+ expect(failureSpy).toHaveBeenCalledWith(
+ expectedEvent,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+ });
+ });
+ });
+
+ describe("rule events: simple", () => {
+ beforeEach(() => simpleSetup());
+
+ it("the rule result is a _copy_ of the rule`s conditions, and unaffected by mutation", async () => {
+ const rule = engine.rules[0];
+ let firstPass;
+ rule.on("success", function (e, almanac, ruleResult) {
+ firstPass = ruleResult;
+ delete ruleResult.conditions.any; // subsequently modify the conditions in this rule result
+ });
+ await engine.run();
+
+ // run the engine again, now that ruleResult.conditions was modified
+ let secondPass;
+ rule.on("success", function (e, almanac, ruleResult) {
+ secondPass = ruleResult;
+ });
+ await engine.run();
+
+ expect(firstPass).toEqual(secondPass); // second pass was unaffected by first pass
+ });
+
+ it("on-success, it passes the event type and params", async () => {
+ const failureSpy = vi.fn();
+ const successSpy = vi.fn();
+ const rule = engine.rules[0];
+ function assertResult(ruleResult) {
+ expect(ruleResult.result).toBe(true);
+ expect(ruleResult.conditions.any[0].result).toBe(true);
+ expect(ruleResult.conditions.any[0].factResult).toBe(21);
+ expect(ruleResult.conditions.any[1].result).toBe(false);
+ expect(ruleResult.conditions.any[1].factResult).toBe(false);
+ }
+
+ rule.on("success", function (e, almanac, ruleResult) {
+ expect(e).toEqual(event);
+ expect(almanac).toBeInstanceOf(Almanac);
+ expect(failureSpy).not.toHaveBeenCalled();
+ assertResult(ruleResult);
+ successSpy();
+ });
+ rule.on("failure", failureSpy);
+
+ const { results, failureResults } = await engine.run();
+
+ assertResult(results[0]);
+ expect(failureResults).toHaveLength(0);
+ expect(results).toHaveLength(1);
+
+ expect(successSpy).toHaveBeenCalledOnce();
+ expect(failureSpy).not.toHaveBeenCalled();
+ });
+
+ it("on-failure, it passes the event type and params", async () => {
+ const AGE = 10;
+ const successSpy = vi.fn();
+ const failureSpy = vi.fn();
+ const rule = engine.rules[0];
+ function assertResult(ruleResult) {
+ expect(ruleResult.result).toBe(false);
+ expect(ruleResult.conditions.any[0].result).toBe(false);
+ expect(ruleResult.conditions.any[0].factResult).toBe(AGE);
+ expect(ruleResult.conditions.any[1].result).toBe(false);
+ expect(ruleResult.conditions.any[1].factResult).toBe(false);
+ }
+ rule.on("failure", function (e, almanac, ruleResult) {
+ expect(e).toEqual(event);
+ expect(almanac).toBeInstanceOf(Almanac);
+ expect(successSpy).not.toHaveBeenCalled();
+ assertResult(ruleResult);
+ failureSpy();
+ });
+ rule.on("success", successSpy);
+ // both conditions will fail
+ engine.addFact("age", AGE);
+ const { results, failureResults } = await engine.run();
+
+ assertResult(failureResults[0]);
+ expect(failureResults).toHaveLength(1);
+ expect(results).toHaveLength(0);
+ expect(failureSpy).toHaveBeenCalledOnce();
+ expect(successSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("rule events: with facts", () => {
+ const expectedEvent = { type: "countedEnough", params: { count: 5 } };
+ const eventWithFact = {
+ type: "countedEnough",
+ params: {
+ count: { fact: "count" },
+ },
+ };
+
+ function setup(replaceFactsInEventParams, event = eventWithFact) {
+ const conditions = {
+ any: [
+ {
+ fact: "success",
+ operator: "equal",
+ value: true,
+ },
+ ],
+ };
+
+ const ruleOptions = { conditions, event, priority: 100 };
+ const countedEnoughRule = ruleFactory(ruleOptions);
+ engine = engineFactory([countedEnoughRule], {
+ replaceFactsInEventParams,
+ });
+ }
+ describe("without flag", () => {
+ beforeEach(() => setup(false));
+ it('"success" passes the event without resolved facts', async () => {
+ const successSpy = vi.fn();
+ engine.rules[0].on("success", successSpy);
+ await engine.run({ success: true, count: 5 });
+ expect(successSpy).toHaveBeenCalledWith(
+ eventWithFact,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it("failure passes the event without resolved facts", async () => {
+ const failureSpy = vi.fn();
+ engine.rules[0].on("failure", failureSpy);
+ await engine.run({ success: false, count: 5 });
+ expect(failureSpy).toHaveBeenCalledWith(
+ eventWithFact,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+ });
+ describe("with flag", () => {
+ beforeEach(() => setup(true));
+ it('"success" passes the event with resolved facts', async () => {
+ const successSpy = vi.fn();
+ engine.rules[0].on("success", successSpy);
+ await engine.run({ success: true, count: 5 });
+ expect(successSpy).toHaveBeenCalledWith(
+ expectedEvent,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it("failure passes the event with resolved facts", async () => {
+ const failureSpy = vi.fn();
+ engine.rules[0].on("failure", failureSpy);
+ await engine.run({ success: false, count: 5 });
+ expect(failureSpy).toHaveBeenCalledWith(
+ expectedEvent,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+ describe("using fact params and path", () => {
+ const eventWithFactWithParamsAndPath = {
+ type: "countedEnough",
+ params: {
+ count: {
+ fact: "count",
+ params: { incrementBy: 5 },
+ path: "$.next",
+ },
+ },
+ };
+
+ beforeEach(() => {
+ setup(true, eventWithFactWithParamsAndPath);
+ engine.addFact(
+ new Fact("count", async ({ incrementBy }) => {
+ return {
+ previous: 0,
+ next: incrementBy,
+ };
+ }),
+ );
+ });
+ it('"success" passes the event with resolved facts', async () => {
+ const successSpy = vi.fn();
+ engine.on("success", successSpy);
+ const { results } = await engine.run({ success: true });
+ expect(results[0].event).toEqual(expectedEvent);
+ expect(successSpy).toHaveBeenCalledWith(
+ expectedEvent,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it("failure passes the event with resolved facts", async () => {
+ const failureSpy = vi.fn();
+ engine.on("failure", failureSpy);
+ const { failureResults } = await engine.run({ success: false });
+ expect(failureResults[0].event).toEqual(expectedEvent);
+ expect(failureSpy).toHaveBeenCalledWith(
+ expectedEvent,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+ });
+ });
+ });
+
+ describe("rule events: json serializing", () => {
+ beforeEach(() => simpleSetup());
+ it("serializes properties", async () => {
+ const successSpy = vi.fn();
+ const rule = engine.rules[0];
+ rule.on("success", successSpy);
+ await engine.run();
+ const ruleResult = successSpy.mock.calls[0][2];
+ const expected =
+ '{"conditions":{"priority":1,"any":[{"name":"over 21","operator":"greaterThanInclusive","value":21,"fact":"age","factResult":21,"result":true},{"operator":"equal","value":true,"fact":"qualified","factResult":false,"result":false}]},"event":{"type":"setDrinkingFlag","params":{"canOrderDrinks":true}},"priority":100,"result":true}';
+ expect(JSON.stringify(ruleResult)).toBe(expected);
+ });
+ });
+});
diff --git a/test/engine-fact-comparison.test.js b/test/engine-fact-comparison.test.js
deleted file mode 100644
index 39af5903..00000000
--- a/test/engine-fact-comparison.test.js
+++ /dev/null
@@ -1,121 +0,0 @@
-'use strict'
-
-import engineFactory from '../src/index'
-import sinon from 'sinon'
-
-describe('Engine: fact to fact comparison', () => {
- let engine
- let sandbox
- before(() => {
- sandbox = sinon.createSandbox()
- })
- afterEach(() => {
- sandbox.restore()
- })
- let eventSpy
-
- function setup (conditions) {
- const event = { type: 'success-event' }
- eventSpy = sandbox.spy()
- engine = engineFactory()
- const rule = factories.rule({ conditions, event })
- engine.addRule(rule)
- engine.on('success', eventSpy)
- }
-
- context('constant facts', () => {
- const constantCondition = {
- all: [{
- fact: 'height',
- operator: 'lessThanInclusive',
- value: {
- fact: 'width'
- }
- }]
- }
- it('allows a fact to retrieve other fact values', async () => {
- setup(constantCondition)
- await engine.run({ height: 1, width: 2 })
- expect(eventSpy).to.have.been.calledOnce()
-
- sandbox.reset()
-
- await engine.run({ height: 2, width: 1 }) // negative case
- expect(eventSpy.callCount).to.equal(0)
- })
- })
-
- context('rules with parameterized conditions', () => {
- const paramsCondition = {
- all: [{
- fact: 'widthMultiplier',
- params: {
- multiplier: 2
- },
- operator: 'equal',
- value: {
- fact: 'heightMultiplier',
- params: {
- multiplier: 4
- }
- }
- }]
- }
- it('honors the params', async () => {
- setup(paramsCondition)
- engine.addFact('heightMultiplier', async (params, almanac) => {
- const height = await almanac.factValue('height')
- return params.multiplier * height
- })
- engine.addFact('widthMultiplier', async (params, almanac) => {
- const width = await almanac.factValue('width')
- return params.multiplier * width
- })
- await engine.run({ height: 5, width: 10 })
- expect(eventSpy).to.have.been.calledOnce()
-
- sandbox.reset()
-
- await engine.run({ height: 5, width: 9 }) // negative case
- expect(eventSpy.callCount).to.equal(0)
- })
- })
-
- context('rules with parameterized conditions and path values', () => {
- const pathCondition = {
- all: [{
- fact: 'widthMultiplier',
- params: {
- multiplier: 2
- },
- path: '$.feet',
- operator: 'equal',
- value: {
- fact: 'heightMultiplier',
- params: {
- multiplier: 4
- },
- path: '$.meters'
- }
- }]
- }
- it('honors the path', async () => {
- setup(pathCondition)
- engine.addFact('heightMultiplier', async (params, almanac) => {
- const height = await almanac.factValue('height')
- return { meters: params.multiplier * height }
- })
- engine.addFact('widthMultiplier', async (params, almanac) => {
- const width = await almanac.factValue('width')
- return { feet: params.multiplier * width }
- })
- await engine.run({ height: 5, width: 10 })
- expect(eventSpy).to.have.been.calledOnce()
-
- sandbox.reset()
-
- await engine.run({ height: 5, width: 9 }) // negative case
- expect(eventSpy.callCount).to.equal(0)
- })
- })
-})
diff --git a/test/engine-fact-comparison.test.mjs b/test/engine-fact-comparison.test.mjs
new file mode 100644
index 00000000..585f33c4
--- /dev/null
+++ b/test/engine-fact-comparison.test.mjs
@@ -0,0 +1,121 @@
+import engineFactory from "../src/index.mjs";
+
+import { describe, it, expect, vi } from "vitest";
+import ruleFactory from "./support/rule-factory.mjs";
+
+describe("Engine: fact to fact comparison", () => {
+ let engine;
+
+ let eventSpy;
+
+ function setup(conditions) {
+ const event = { type: "success-event" };
+ eventSpy = vi.fn();
+ engine = engineFactory();
+ const rule = ruleFactory({ conditions, event });
+ engine.addRule(rule);
+ engine.on("success", eventSpy);
+ }
+
+ describe("constant facts", () => {
+ const constantCondition = {
+ all: [
+ {
+ fact: "height",
+ operator: "lessThanInclusive",
+ value: {
+ fact: "width",
+ },
+ },
+ ],
+ };
+ it("allows a fact to retrieve other fact values", async () => {
+ setup(constantCondition);
+ await engine.run({ height: 1, width: 2 });
+ expect(eventSpy).toHaveBeenCalledOnce();
+
+ eventSpy.mockReset();
+
+ await engine.run({ height: 2, width: 1 }); // negative case
+ expect(eventSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("rules with parameterized conditions", () => {
+ const paramsCondition = {
+ all: [
+ {
+ fact: "widthMultiplier",
+ params: {
+ multiplier: 2,
+ },
+ operator: "equal",
+ value: {
+ fact: "heightMultiplier",
+ params: {
+ multiplier: 4,
+ },
+ },
+ },
+ ],
+ };
+ it("honors the params", async () => {
+ setup(paramsCondition);
+ engine.addFact("heightMultiplier", async (params, almanac) => {
+ const height = await almanac.factValue("height");
+ return params.multiplier * height;
+ });
+ engine.addFact("widthMultiplier", async (params, almanac) => {
+ const width = await almanac.factValue("width");
+ return params.multiplier * width;
+ });
+ await engine.run({ height: 5, width: 10 });
+ expect(eventSpy).toHaveBeenCalledOnce();
+
+ eventSpy.mockReset();
+
+ await engine.run({ height: 5, width: 9 }); // negative case
+ expect(eventSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("rules with parameterized conditions and path values", () => {
+ const pathCondition = {
+ all: [
+ {
+ fact: "widthMultiplier",
+ params: {
+ multiplier: 2,
+ },
+ path: "$.feet",
+ operator: "equal",
+ value: {
+ fact: "heightMultiplier",
+ params: {
+ multiplier: 4,
+ },
+ path: "$.meters",
+ },
+ },
+ ],
+ };
+ it("honors the path", async () => {
+ setup(pathCondition);
+ engine.addFact("heightMultiplier", async (params, almanac) => {
+ const height = await almanac.factValue("height");
+ return { meters: params.multiplier * height };
+ });
+ engine.addFact("widthMultiplier", async (params, almanac) => {
+ const width = await almanac.factValue("width");
+ return { feet: params.multiplier * width };
+ });
+ await engine.run({ height: 5, width: 10 });
+ expect(eventSpy).toHaveBeenCalledOnce();
+
+ eventSpy.mockReset();
+
+ await engine.run({ height: 5, width: 9 }); // negative case
+ expect(eventSpy).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/test/engine-fact-priority.test.js b/test/engine-fact-priority.test.js
deleted file mode 100644
index 2d37e739..00000000
--- a/test/engine-fact-priority.test.js
+++ /dev/null
@@ -1,188 +0,0 @@
-'use strict'
-
-import engineFactory from '../src/index'
-import sinon from 'sinon'
-
-describe('Engine: fact priority', () => {
- let engine
- let sandbox
- before(() => {
- sandbox = sinon.createSandbox()
- })
- afterEach(() => {
- sandbox.restore()
- })
- const event = { type: 'adult-human-admins' }
-
- let eventSpy
- let failureSpy
- let ageStub
- let segmentStub
- let accountTypeStub
-
- function setup (conditions) {
- ageStub = sandbox.stub()
- segmentStub = sandbox.stub()
- accountTypeStub = sandbox.stub()
- eventSpy = sandbox.stub()
- failureSpy = sandbox.stub()
-
- engine = engineFactory()
- const rule = factories.rule({ conditions, event })
- engine.addRule(rule)
- engine.addFact('age', ageStub, { priority: 100 })
- engine.addFact('segment', segmentStub, { priority: 50 })
- engine.addFact('accountType', accountTypeStub, { priority: 25 })
- engine.on('success', eventSpy)
- engine.on('failure', failureSpy)
- }
-
- describe('all conditions', () => {
- const allCondition = {
- all: [{
- fact: 'age',
- operator: 'greaterThanInclusive',
- value: 18
- }, {
- fact: 'segment',
- operator: 'equal',
- value: 'human'
- }, {
- fact: 'accountType',
- operator: 'equal',
- value: 'admin'
- }]
- }
-
- it('stops on the first fact to fail, part 1', async () => {
- setup(allCondition)
- ageStub.returns(10) // fail
- await engine.run()
- expect(failureSpy).to.have.been.called()
- expect(eventSpy).to.not.have.been.called()
- expect(ageStub).to.have.been.calledOnce()
- expect(segmentStub).to.not.have.been.called()
- expect(accountTypeStub).to.not.have.been.called()
- })
-
- it('stops on the first fact to fail, part 2', async () => {
- setup(allCondition)
- ageStub.returns(20) // pass
- segmentStub.returns('android') // fail
- await engine.run()
- expect(failureSpy).to.have.been.called()
- expect(eventSpy).to.not.have.been.called()
- expect(ageStub).to.have.been.calledOnce()
- expect(segmentStub).to.have.been.calledOnce()
- expect(accountTypeStub).to.not.have.been.called()
- })
-
- describe('sub-conditions', () => {
- const allSubCondition = {
- all: [{
- fact: 'age',
- operator: 'greaterThanInclusive',
- value: 18
- }, {
- all: [
- {
- fact: 'segment',
- operator: 'equal',
- value: 'human'
- }, {
- fact: 'accountType',
- operator: 'equal',
- value: 'admin'
- }
- ]
- }]
- }
-
- it('stops after the first sub-condition fact fails', async () => {
- setup(allSubCondition)
- ageStub.returns(20) // pass
- segmentStub.returns('android') // fail
- await engine.run()
- expect(failureSpy).to.have.been.called()
- expect(eventSpy).to.not.have.been.called()
- expect(ageStub).to.have.been.calledOnce()
- expect(segmentStub).to.have.been.calledOnce()
- expect(accountTypeStub).to.not.have.been.called()
- })
- })
- })
-
- describe('any conditions', () => {
- const anyCondition = {
- any: [{
- fact: 'age',
- operator: 'greaterThanInclusive',
- value: 18
- }, {
- fact: 'segment',
- operator: 'equal',
- value: 'human'
- }, {
- fact: 'accountType',
- operator: 'equal',
- value: 'admin'
- }]
- }
- it('complete on the first fact to succeed, part 1', async () => {
- setup(anyCondition)
- ageStub.returns(20) // succeed
- await engine.run()
- expect(eventSpy).to.have.been.calledOnce()
- expect(failureSpy).to.not.have.been.called()
- expect(ageStub).to.have.been.calledOnce()
- expect(segmentStub).to.not.have.been.called()
- expect(accountTypeStub).to.not.have.been.called()
- })
-
- it('short circuits on the first fact to fail, part 2', async () => {
- setup(anyCondition)
- ageStub.returns(10) // fail
- segmentStub.returns('human') // pass
- await engine.run()
- expect(eventSpy).to.have.been.calledOnce()
- expect(failureSpy).to.not.have.been.called()
- expect(ageStub).to.have.been.calledOnce()
- expect(segmentStub).to.have.been.calledOnce()
- expect(accountTypeStub).to.not.have.been.called()
- })
-
- describe('sub-conditions', () => {
- const anySubCondition = {
- all: [{
- fact: 'age',
- operator: 'greaterThanInclusive',
- value: 18
- }, {
- any: [
- {
- fact: 'segment',
- operator: 'equal',
- value: 'human'
- }, {
- fact: 'accountType',
- operator: 'equal',
- value: 'admin'
- }
- ]
- }]
- }
-
- it('stops after the first sub-condition fact succeeds', async () => {
- setup(anySubCondition)
- ageStub.returns(20) // success
- segmentStub.returns('human') // success
- await engine.run()
- expect(failureSpy).to.not.have.been.called()
- expect(eventSpy).to.have.been.called()
- expect(ageStub).to.have.been.calledOnce()
- expect(segmentStub).to.have.been.calledOnce()
- expect(accountTypeStub).to.not.have.been.called()
- })
- })
- })
-})
diff --git a/test/engine-fact-priority.test.mjs b/test/engine-fact-priority.test.mjs
new file mode 100644
index 00000000..ffb2e692
--- /dev/null
+++ b/test/engine-fact-priority.test.mjs
@@ -0,0 +1,198 @@
+import engineFactory from "../src/index.mjs";
+
+import { describe, it, expect, vi } from "vitest";
+import ruleFactory from "./support/rule-factory.mjs";
+
+describe("Engine: fact priority", () => {
+ let engine;
+
+ const event = { type: "adult-human-admins" };
+
+ let eventSpy;
+ let failureSpy;
+ let ageStub;
+ let segmentStub;
+ let accountTypeStub;
+
+ function setup(conditions) {
+ ageStub = vi.fn();
+ segmentStub = vi.fn();
+ accountTypeStub = vi.fn();
+ eventSpy = vi.fn();
+ failureSpy = vi.fn();
+
+ engine = engineFactory();
+ const rule = ruleFactory({ conditions, event });
+ engine.addRule(rule);
+ engine.addFact("age", ageStub, { priority: 100 });
+ engine.addFact("segment", segmentStub, { priority: 50 });
+ engine.addFact("accountType", accountTypeStub, { priority: 25 });
+ engine.on("success", eventSpy);
+ engine.on("failure", failureSpy);
+ }
+
+ describe("all conditions", () => {
+ const allCondition = {
+ all: [
+ {
+ fact: "age",
+ operator: "greaterThanInclusive",
+ value: 18,
+ },
+ {
+ fact: "segment",
+ operator: "equal",
+ value: "human",
+ },
+ {
+ fact: "accountType",
+ operator: "equal",
+ value: "admin",
+ },
+ ],
+ };
+
+ it("stops on the first fact to fail, part 1", async () => {
+ setup(allCondition);
+ ageStub.mockReturnValue(10); // fail
+ await engine.run();
+ expect(failureSpy).toHaveBeenCalled();
+ expect(eventSpy).not.toHaveBeenCalled();
+ expect(ageStub).toHaveBeenCalledOnce();
+ expect(segmentStub).not.toHaveBeenCalled();
+ expect(accountTypeStub).not.toHaveBeenCalled();
+ });
+
+ it("stops on the first fact to fail, part 2", async () => {
+ setup(allCondition);
+ ageStub.mockReturnValue(20); // pass
+ segmentStub.mockReturnValue("android"); // fail
+ await engine.run();
+ expect(failureSpy).toHaveBeenCalled();
+ expect(eventSpy).not.toHaveBeenCalled();
+ expect(ageStub).toHaveBeenCalledOnce();
+ expect(segmentStub).toHaveBeenCalledOnce();
+ expect(accountTypeStub).not.toHaveBeenCalled();
+ });
+
+ describe("sub-conditions", () => {
+ const allSubCondition = {
+ all: [
+ {
+ fact: "age",
+ operator: "greaterThanInclusive",
+ value: 18,
+ },
+ {
+ all: [
+ {
+ fact: "segment",
+ operator: "equal",
+ value: "human",
+ },
+ {
+ fact: "accountType",
+ operator: "equal",
+ value: "admin",
+ },
+ ],
+ },
+ ],
+ };
+
+ it("stops after the first sub-condition fact fails", async () => {
+ setup(allSubCondition);
+ ageStub.mockReturnValue(20); // pass
+ segmentStub.mockReturnValue("android"); // fail
+ await engine.run();
+ expect(failureSpy).toHaveBeenCalled();
+ expect(eventSpy).not.toHaveBeenCalled();
+ expect(ageStub).toHaveBeenCalledOnce();
+ expect(segmentStub).toHaveBeenCalledOnce();
+ expect(accountTypeStub).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe("any conditions", () => {
+ const anyCondition = {
+ any: [
+ {
+ fact: "age",
+ operator: "greaterThanInclusive",
+ value: 18,
+ },
+ {
+ fact: "segment",
+ operator: "equal",
+ value: "human",
+ },
+ {
+ fact: "accountType",
+ operator: "equal",
+ value: "admin",
+ },
+ ],
+ };
+ it("complete on the first fact to succeed, part 1", async () => {
+ setup(anyCondition);
+ ageStub.mockReturnValue(20); // succeed
+ await engine.run();
+ expect(eventSpy).toHaveBeenCalledOnce();
+ expect(failureSpy).not.toHaveBeenCalled();
+ expect(ageStub).toHaveBeenCalledOnce();
+ expect(segmentStub).not.toHaveBeenCalled();
+ expect(accountTypeStub).not.toHaveBeenCalled();
+ });
+
+ it("short circuits on the first fact to fail, part 2", async () => {
+ setup(anyCondition);
+ ageStub.mockReturnValue(10); // fail
+ segmentStub.mockReturnValue("human"); // pass
+ await engine.run();
+ expect(eventSpy).toHaveBeenCalledOnce();
+ expect(failureSpy).not.toHaveBeenCalled();
+ expect(ageStub).toHaveBeenCalledOnce();
+ expect(segmentStub).toHaveBeenCalledOnce();
+ expect(accountTypeStub).not.toHaveBeenCalled();
+ });
+
+ describe("sub-conditions", () => {
+ const anySubCondition = {
+ all: [
+ {
+ fact: "age",
+ operator: "greaterThanInclusive",
+ value: 18,
+ },
+ {
+ any: [
+ {
+ fact: "segment",
+ operator: "equal",
+ value: "human",
+ },
+ {
+ fact: "accountType",
+ operator: "equal",
+ value: "admin",
+ },
+ ],
+ },
+ ],
+ };
+
+ it("stops after the first sub-condition fact succeeds", async () => {
+ setup(anySubCondition);
+ ageStub.mockReturnValue(20); // success
+ segmentStub.mockReturnValue("human"); // success
+ await engine.run();
+ expect(failureSpy).not.toHaveBeenCalled();
+ expect(eventSpy).toHaveBeenCalled();
+ expect(ageStub).toHaveBeenCalledOnce();
+ expect(segmentStub).toHaveBeenCalledOnce();
+ expect(accountTypeStub).not.toHaveBeenCalled();
+ });
+ });
+ });
+});
diff --git a/test/engine-fact.test.js b/test/engine-fact.test.js
deleted file mode 100644
index 93d9b991..00000000
--- a/test/engine-fact.test.js
+++ /dev/null
@@ -1,336 +0,0 @@
-'use strict'
-
-import sinon from 'sinon'
-import { get } from 'lodash'
-import engineFactory from '../src/index'
-
-const CHILD = 14
-const ADULT = 75
-
-async function eligibilityField (params, engine) {
- if (params.field === 'age') {
- if (params.eligibilityId === 1) {
- return CHILD
- }
- return ADULT
- }
-}
-
-async function eligibilityData (params, engine) {
- const address = {
- street: '123 Fake Street',
- state: {
- abbreviation: 'CO',
- name: 'Colorado'
- },
- zip: '80403',
- 'dot.property': 'dot-property-value',
- occupantHistory: [
- { name: 'Joe', year: 2011 },
- { name: 'Jane', year: 2013 }
- ],
- currentOccupants: [
- { name: 'Larry', year: 2020 }
- ]
- }
- if (params.eligibilityId === 1) {
- return { age: CHILD, address }
- }
- return { age: ADULT, address }
-}
-
-describe('Engine: fact evaluation', () => {
- let engine
- let sandbox
- before(() => {
- sandbox = sinon.createSandbox()
- })
- afterEach(() => {
- sandbox.restore()
- })
- const event = {
- type: 'ageTrigger',
- params: {
- demographic: 'under50'
- }
- }
- function baseConditions () {
- return {
- any: [{
- fact: 'eligibilityField',
- operator: 'lessThan',
- params: {
- eligibilityId: 1,
- field: 'age'
- },
- value: 50
- }]
- }
- }
- let successSpy
- let failureSpy
- beforeEach(() => {
- successSpy = sandbox.spy()
- failureSpy = sandbox.spy()
- })
-
- function setup (conditions = baseConditions(), engineOptions = {}) {
- engine = engineFactory([], engineOptions)
- const rule = factories.rule({ conditions, event })
- engine.addRule(rule)
- engine.addFact('eligibilityField', eligibilityField)
- engine.addFact('eligibilityData', eligibilityData)
- engine.on('success', successSpy)
- engine.on('failure', failureSpy)
- }
-
- describe('options', () => {
- describe('options.allowUndefinedFacts', () => {
- it('throws when fact is undefined by default', async () => {
- const conditions = Object.assign({}, baseConditions())
- conditions.any.push({
- fact: 'undefined-fact',
- operator: 'equal',
- value: true
- })
- setup(conditions)
- return expect(engine.run()).to.be.rejectedWith(/Undefined fact: undefined-fact/)
- })
-
- context('treats undefined facts as falsey when allowUndefinedFacts is set', () => {
- it('emits "success" when the condition succeeds', async () => {
- const conditions = Object.assign({}, baseConditions())
- conditions.any.push({
- fact: 'undefined-fact',
- operator: 'equal',
- value: true
- })
- setup(conditions, { allowUndefinedFacts: true })
- await engine.run()
- expect(successSpy).to.have.been.called()
- expect(failureSpy).to.not.have.been.called()
- })
-
- it('emits "failure" when the condition fails', async () => {
- const conditions = Object.assign({}, baseConditions())
- conditions.any.push({
- fact: 'undefined-fact',
- operator: 'equal',
- value: true
- })
- conditions.any[0].params.eligibilityId = 2
- setup(conditions, { allowUndefinedFacts: true })
- await engine.run()
- expect(successSpy).to.not.have.been.called()
- expect(failureSpy).to.have.been.called()
- })
- })
- })
- })
-
- describe('params', () => {
- it('emits when the condition is met', async () => {
- setup()
- await engine.run()
- expect(successSpy).to.have.been.calledWith(event)
- })
-
- it('does not emit when the condition fails', async () => {
- const conditions = Object.assign({}, baseConditions())
- conditions.any[0].params.eligibilityId = 2
- setup(conditions)
- await engine.run()
- expect(successSpy).to.not.have.been.called()
- })
- })
-
- describe('path', () => {
- function conditions () {
- return {
- any: [{
- fact: 'eligibilityData',
- operator: 'lessThan',
- path: '$.age',
- params: {
- eligibilityId: 1
- },
- value: 50
- }]
- }
- }
- it('emits when the condition is met', async () => {
- setup(conditions())
- await engine.run()
- expect(successSpy).to.have.been.calledWith(event)
- })
-
- it('does not emit when the condition fails', async () => {
- const failureCondition = conditions()
- failureCondition.any[0].params.eligibilityId = 2
- setup(failureCondition)
- await engine.run()
- expect(successSpy).to.not.have.been.called()
- })
-
- describe('arrays', () => {
- it('can extract an array, allowing it to be used in concert with array operators', async () => {
- const complexCondition = conditions()
- complexCondition.any[0].path = '$.address.occupantHistory[*].year'
- complexCondition.any[0].value = 2011
- complexCondition.any[0].operator = 'contains'
- setup(complexCondition)
- await engine.run()
- expect(successSpy).to.have.been.calledWith(event)
- })
-
- it('can extract an array with a single element', async () => {
- const complexCondition = conditions()
- complexCondition.any[0].path = '$.address.currentOccupants[*].year'
- complexCondition.any[0].value = 2020
- complexCondition.any[0].operator = 'contains'
- setup(complexCondition)
- await engine.run()
- expect(successSpy).to.have.been.calledWith(event)
- })
- })
-
- context('complex paths', () => {
- it('correctly interprets "path" when dynamic facts return objects', async () => {
- const complexCondition = conditions()
- complexCondition.any[0].path = '$.address.occupantHistory[0].year'
- complexCondition.any[0].value = 2011
- complexCondition.any[0].operator = 'equal'
- setup(complexCondition)
- await engine.run()
- expect(successSpy).to.have.been.calledWith(event)
- })
-
- it('correctly interprets "path" when target object properties have dots', async () => {
- const complexCondition = conditions()
- complexCondition.any[0].path = '$.address.[\'dot.property\']'
- complexCondition.any[0].value = 'dot-property-value'
- complexCondition.any[0].operator = 'equal'
- setup(complexCondition)
- await engine.run()
- expect(successSpy).to.have.been.calledWith(event)
- })
-
- it('correctly interprets "path" with runtime fact objects', async () => {
- const fact = { x: { y: 1 }, a: 2 }
- const conditions = {
- all: [{
- fact: 'x',
- path: '$.y',
- operator: 'equal',
- value: 1
- }]
- }
-
- engine = engineFactory([])
- const rule = factories.rule({ conditions, event })
- engine.addRule(rule)
- engine.on('success', successSpy)
- engine.on('failure', failureSpy)
- await engine.run(fact)
- expect(successSpy).to.have.been.calledWith(event)
- expect(failureSpy).to.not.have.been.calledWith(event)
- })
- })
-
- it('does not emit when complex object paths fail the condition', async () => {
- const complexCondition = conditions()
- complexCondition.any[0].path = '$.address.occupantHistory[0].year'
- complexCondition.any[0].value = 2010
- complexCondition.any[0].operator = 'equal'
- setup(complexCondition)
- await engine.run()
- expect(successSpy).to.not.have.been.calledWith(event)
- })
-
- it('treats invalid object paths as undefined', async () => {
- const complexCondition = conditions()
- complexCondition.any[0].path = '$.invalid.object[99].path'
- complexCondition.any[0].value = undefined
- complexCondition.any[0].operator = 'equal'
- setup(complexCondition)
- await engine.run()
- expect(successSpy).to.have.been.calledWith(event)
- })
-
- it('ignores "path" when facts return non-objects', async () => {
- setup(conditions())
- const eligibilityData = async (params, engine) => {
- return CHILD
- }
- engine.addFact('eligibilityData', eligibilityData)
- await engine.run()
- expect(successSpy).to.have.been.calledWith(event)
- })
-
- describe('pathResolver', () => {
- it('allows a custom path resolver to be registered which interprets the path property', async () => {
- const fact = { x: { y: [99] }, a: 2 }
- const conditions = {
- all: [{
- fact: 'x',
- path: 'y[0]',
- operator: 'equal',
- value: 99
- }]
- }
- const pathResolver = (value, path) => {
- return get(value, path)
- }
-
- engine = engineFactory([], { pathResolver })
- const rule = factories.rule({ conditions, event })
- engine.addRule(rule)
- engine.on('success', successSpy)
- engine.on('failure', failureSpy)
-
- await engine.run(fact)
-
- expect(successSpy).to.have.been.calledWith(event)
- expect(failureSpy).to.not.have.been.called()
- })
- })
- })
-
- describe('promises', () => {
- it('works with asynchronous evaluations', async () => {
- setup()
- const eligibilityField = function (params, engine) {
- return new Promise((resolve, reject) => {
- setImmediate(() => {
- resolve(30)
- })
- })
- }
- engine.addFact('eligibilityField', eligibilityField)
- await engine.run()
- expect(successSpy).to.have.been.called()
- })
- })
-
- describe('synchronous functions', () => {
- it('works with synchronous, non-promise evaluations that are truthy', async () => {
- setup()
- const eligibilityField = function (params, engine) {
- return 20
- }
- engine.addFact('eligibilityField', eligibilityField)
- await engine.run()
- expect(successSpy).to.have.been.called()
- })
-
- it('works with synchronous, non-promise evaluations that are falsey', async () => {
- setup()
- const eligibilityField = function (params, engine) {
- return 100
- }
- engine.addFact('eligibilityField', eligibilityField)
- await engine.run()
- expect(successSpy).to.not.have.been.called()
- })
- })
-})
diff --git a/test/engine-fact.test.mjs b/test/engine-fact.test.mjs
new file mode 100644
index 00000000..22e94d00
--- /dev/null
+++ b/test/engine-fact.test.mjs
@@ -0,0 +1,385 @@
+import { get } from "lodash";
+import engineFactory from "../src/index.mjs";
+import { describe, it, beforeEach, expect, vi } from "vitest";
+import ruleFactory from "./support/rule-factory.mjs";
+
+const CHILD = 14;
+const ADULT = 75;
+
+async function eligibilityField(params) {
+ if (params.field === "age") {
+ if (params.eligibilityId === 1) {
+ return CHILD;
+ }
+ return ADULT;
+ }
+}
+
+async function eligibilityData(params) {
+ const address = {
+ street: "123 Fake Street",
+ state: {
+ abbreviation: "CO",
+ name: "Colorado",
+ },
+ zip: "80403",
+ "dot.property": "dot-property-value",
+ occupantHistory: [
+ { name: "Joe", year: 2011 },
+ { name: "Jane", year: 2013 },
+ ],
+ currentOccupants: [{ name: "Larry", year: 2020 }],
+ };
+ if (params.eligibilityId === 1) {
+ return { age: CHILD, address };
+ }
+ return { age: ADULT, address };
+}
+
+describe("Engine: fact evaluation", () => {
+ let engine;
+
+ const event = {
+ type: "ageTrigger",
+ params: {
+ demographic: "under50",
+ },
+ };
+ function baseConditions() {
+ return {
+ any: [
+ {
+ fact: "eligibilityField",
+ operator: "lessThan",
+ params: {
+ eligibilityId: 1,
+ field: "age",
+ },
+ value: 50,
+ },
+ ],
+ };
+ }
+ let successSpy;
+ let failureSpy;
+ beforeEach(() => {
+ successSpy = vi.fn();
+ failureSpy = vi.fn();
+ });
+
+ function setup(conditions = baseConditions(), engineOptions = {}) {
+ engine = engineFactory([], engineOptions);
+ const rule = ruleFactory({ conditions, event });
+ engine.addRule(rule);
+ engine.addFact("eligibilityField", eligibilityField);
+ engine.addFact("eligibilityData", eligibilityData);
+ engine.on("success", successSpy);
+ engine.on("failure", failureSpy);
+ }
+
+ describe("options", () => {
+ describe("options.allowUndefinedFacts", () => {
+ it("throws when fact is undefined by default", async () => {
+ const conditions = Object.assign({}, baseConditions());
+ conditions.any.push({
+ fact: "undefined-fact",
+ operator: "equal",
+ value: true,
+ });
+ setup(conditions);
+ return expect(engine.run()).rejects.toThrow(
+ /Undefined fact: undefined-fact/,
+ );
+ });
+
+ describe("treats undefined facts as falsey when allowUndefinedFacts is set", () => {
+ it('emits "success" when the condition succeeds', async () => {
+ const conditions = Object.assign({}, baseConditions());
+ conditions.any.push({
+ fact: "undefined-fact",
+ operator: "equal",
+ value: true,
+ });
+ setup(conditions, { allowUndefinedFacts: true });
+ await engine.run();
+ expect(successSpy).toHaveBeenCalled();
+ expect(failureSpy).not.toHaveBeenCalled();
+ });
+
+ it('emits "failure" when the condition fails', async () => {
+ const conditions = Object.assign({}, baseConditions());
+ conditions.any.push({
+ fact: "undefined-fact",
+ operator: "equal",
+ value: true,
+ });
+ conditions.any[0].params.eligibilityId = 2;
+ setup(conditions, { allowUndefinedFacts: true });
+ await engine.run();
+ expect(successSpy).not.toHaveBeenCalled();
+ expect(failureSpy).toHaveBeenCalled();
+ });
+ });
+ });
+ });
+
+ describe("params", () => {
+ it("emits when the condition is met", async () => {
+ setup();
+ await engine.run();
+ expect(successSpy).toHaveBeenCalledWith(
+ event,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it("does not emit when the condition fails", async () => {
+ const conditions = Object.assign({}, baseConditions());
+ conditions.any[0].params.eligibilityId = 2;
+ setup(conditions);
+ await engine.run();
+ expect(successSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("path", () => {
+ function conditions() {
+ return {
+ any: [
+ {
+ fact: "eligibilityData",
+ operator: "lessThan",
+ path: "$.age",
+ params: {
+ eligibilityId: 1,
+ },
+ value: 50,
+ },
+ ],
+ };
+ }
+ it("emits when the condition is met", async () => {
+ setup(conditions());
+ await engine.run();
+ expect(successSpy).toHaveBeenCalledWith(
+ event,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it("does not emit when the condition fails", async () => {
+ const failureCondition = conditions();
+ failureCondition.any[0].params.eligibilityId = 2;
+ setup(failureCondition);
+ await engine.run();
+ expect(successSpy).not.toHaveBeenCalled();
+ });
+
+ describe("arrays", () => {
+ it("can extract an array, allowing it to be used in concert with array operators", async () => {
+ const complexCondition = conditions();
+ complexCondition.any[0].path = "$.address.occupantHistory[*].year";
+ complexCondition.any[0].value = 2011;
+ complexCondition.any[0].operator = "contains";
+ setup(complexCondition);
+ await engine.run();
+ expect(successSpy).toHaveBeenCalledWith(
+ event,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it("can extract an array with a single element", async () => {
+ const complexCondition = conditions();
+ complexCondition.any[0].path = "$.address.currentOccupants[*].year";
+ complexCondition.any[0].value = 2020;
+ complexCondition.any[0].operator = "contains";
+ setup(complexCondition);
+ await engine.run();
+ expect(successSpy).toHaveBeenCalledWith(
+ event,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+ });
+
+ describe("complex paths", () => {
+ it('correctly interprets "path" when dynamic facts return objects', async () => {
+ const complexCondition = conditions();
+ complexCondition.any[0].path = "$.address.occupantHistory[0].year";
+ complexCondition.any[0].value = 2011;
+ complexCondition.any[0].operator = "equal";
+ setup(complexCondition);
+ await engine.run();
+ expect(successSpy).toHaveBeenCalledWith(
+ event,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it('correctly interprets "path" when target object properties have dots', async () => {
+ const complexCondition = conditions();
+ complexCondition.any[0].path = "$.address.['dot.property']";
+ complexCondition.any[0].value = "dot-property-value";
+ complexCondition.any[0].operator = "equal";
+ setup(complexCondition);
+ await engine.run();
+ expect(successSpy).toHaveBeenCalledWith(
+ event,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it('correctly interprets "path" with runtime fact objects', async () => {
+ const fact = { x: { y: 1 }, a: 2 };
+ const conditions = {
+ all: [
+ {
+ fact: "x",
+ path: "$.y",
+ operator: "equal",
+ value: 1,
+ },
+ ],
+ };
+
+ engine = engineFactory([]);
+ const rule = ruleFactory({ conditions, event });
+ engine.addRule(rule);
+ engine.on("success", successSpy);
+ engine.on("failure", failureSpy);
+ await engine.run(fact);
+ expect(successSpy).toHaveBeenCalledWith(
+ event,
+ expect.anything(),
+ expect.anything(),
+ );
+ expect(failureSpy).not.toHaveBeenCalledWith(
+ event,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+ });
+
+ it("does not emit when complex object paths fail the condition", async () => {
+ const complexCondition = conditions();
+ complexCondition.any[0].path = "$.address.occupantHistory[0].year";
+ complexCondition.any[0].value = 2010;
+ complexCondition.any[0].operator = "equal";
+ setup(complexCondition);
+ await engine.run();
+ expect(successSpy).not.toHaveBeenCalledWith(
+ event,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it("treats invalid object paths as undefined", async () => {
+ const complexCondition = conditions();
+ complexCondition.any[0].path = "$.invalid.object[99].path";
+ complexCondition.any[0].value = undefined;
+ complexCondition.any[0].operator = "equal";
+ setup(complexCondition);
+ await engine.run();
+ expect(successSpy).toHaveBeenCalledWith(
+ event,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it('ignores "path" when facts return non-objects', async () => {
+ setup(conditions());
+ const eligibilityData = async () => {
+ return CHILD;
+ };
+ engine.addFact("eligibilityData", eligibilityData);
+ await engine.run();
+ expect(successSpy).toHaveBeenCalledWith(
+ event,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ describe("pathResolver", () => {
+ it("allows a custom path resolver to be registered which interprets the path property", async () => {
+ const fact = { x: { y: [99] }, a: 2 };
+ const conditions = {
+ all: [
+ {
+ fact: "x",
+ path: "y[0]",
+ operator: "equal",
+ value: 99,
+ },
+ ],
+ };
+ const pathResolver = (value, path) => {
+ return get(value, path);
+ };
+
+ engine = engineFactory([], { pathResolver });
+ const rule = ruleFactory({ conditions, event });
+ engine.addRule(rule);
+ engine.on("success", successSpy);
+ engine.on("failure", failureSpy);
+
+ await engine.run(fact);
+
+ expect(successSpy).toHaveBeenCalledWith(
+ event,
+ expect.anything(),
+ expect.anything(),
+ );
+ expect(failureSpy).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe("promises", () => {
+ it("works with asynchronous evaluations", async () => {
+ setup();
+ const eligibilityField = function () {
+ return new Promise((resolve) => {
+ setImmediate(() => {
+ resolve(30);
+ });
+ });
+ };
+ engine.addFact("eligibilityField", eligibilityField);
+ await engine.run();
+ expect(successSpy).toHaveBeenCalled();
+ });
+ });
+
+ describe("synchronous functions", () => {
+ it("works with synchronous, non-promise evaluations that are truthy", async () => {
+ setup();
+ const eligibilityField = function () {
+ return 20;
+ };
+ engine.addFact("eligibilityField", eligibilityField);
+ await engine.run();
+ expect(successSpy).toHaveBeenCalled();
+ });
+
+ it("works with synchronous, non-promise evaluations that are falsey", async () => {
+ setup();
+ const eligibilityField = function () {
+ return 100;
+ };
+ engine.addFact("eligibilityField", eligibilityField);
+ await engine.run();
+ expect(successSpy).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/test/engine-facts-calling-facts.test.js b/test/engine-facts-calling-facts.test.js
deleted file mode 100644
index 19b35554..00000000
--- a/test/engine-facts-calling-facts.test.js
+++ /dev/null
@@ -1,97 +0,0 @@
-'use strict'
-
-import engineFactory, { Fact } from '../src/index'
-import sinon from 'sinon'
-
-describe('Engine: custom cache keys', () => {
- let engine
- let sandbox
- before(() => {
- sandbox = sinon.createSandbox()
- })
- afterEach(() => {
- sandbox.restore()
- })
- const event = { type: 'early-twenties' }
- const conditions = {
- all: [{
- fact: 'demographics',
- params: {
- field: 'age'
- },
- operator: 'lessThanInclusive',
- value: 25
- }, {
- fact: 'demographics',
- params: {
- field: 'zipCode'
- },
- operator: 'equal',
- value: 80211
- }]
- }
-
- let eventSpy
- let demographicDataSpy
- let demographicSpy
- beforeEach(() => {
- demographicSpy = sandbox.spy()
- demographicDataSpy = sandbox.spy()
- eventSpy = sandbox.spy()
-
- const demographicsDataDefinition = async (params, engine) => {
- demographicDataSpy()
- return {
- age: 20,
- zipCode: 80211
- }
- }
-
- const demographicsDefinition = async (params, engine) => {
- demographicSpy()
- const data = await engine.factValue('demographic-data')
- return data[params.field]
- }
- const demographicsFact = new Fact('demographics', demographicsDefinition)
- const demographicsDataFact = new Fact('demographic-data', demographicsDataDefinition)
-
- engine = engineFactory()
- const rule = factories.rule({ conditions, event })
- engine.addRule(rule)
- engine.addFact(demographicsFact)
- engine.addFact(demographicsDataFact)
- engine.on('success', eventSpy)
- })
-
- describe('1 rule', () => {
- it('allows a fact to retrieve other fact values', async () => {
- await engine.run()
- expect(eventSpy).to.have.been.calledOnce()
- expect(demographicDataSpy).to.have.been.calledOnce()
- expect(demographicSpy).to.have.been.calledTwice()
- })
- })
-
- describe('2 rules with parallel conditions', () => {
- it('calls the fact definition once', async () => {
- const conditions = {
- all: [{
- fact: 'demographics',
- params: {
- field: 'age'
- },
- operator: 'greaterThanInclusive',
- value: 20
- }]
- }
- const rule = factories.rule({ conditions, event })
- engine.addRule(rule)
-
- await engine.run()
- expect(eventSpy).to.have.been.calledTwice()
- expect(demographicDataSpy).to.have.been.calledOnce()
- expect(demographicSpy).to.have.been.calledTwice()
- expect(demographicDataSpy).to.have.been.calledOnce()
- })
- })
-})
diff --git a/test/engine-facts-calling-facts.test.mjs b/test/engine-facts-calling-facts.test.mjs
new file mode 100644
index 00000000..631fdf8e
--- /dev/null
+++ b/test/engine-facts-calling-facts.test.mjs
@@ -0,0 +1,99 @@
+import engineFactory, { Fact } from "../src/index.mjs";
+
+import { describe, it, beforeEach, expect, vi } from "vitest";
+import ruleFactory from "./support/rule-factory.mjs";
+
+describe("Engine: custom cache keys", () => {
+ let engine;
+
+ const event = { type: "early-twenties" };
+ const conditions = {
+ all: [
+ {
+ fact: "demographics",
+ params: {
+ field: "age",
+ },
+ operator: "lessThanInclusive",
+ value: 25,
+ },
+ {
+ fact: "demographics",
+ params: {
+ field: "zipCode",
+ },
+ operator: "equal",
+ value: 80211,
+ },
+ ],
+ };
+
+ let eventSpy;
+ let demographicDataSpy;
+ let demographicSpy;
+ beforeEach(() => {
+ demographicSpy = vi.fn();
+ demographicDataSpy = vi.fn();
+ eventSpy = vi.fn();
+
+ const demographicsDataDefinition = async () => {
+ demographicDataSpy();
+ return {
+ age: 20,
+ zipCode: 80211,
+ };
+ };
+
+ const demographicsDefinition = async (params, engine) => {
+ demographicSpy();
+ const data = await engine.factValue("demographic-data");
+ return data[params.field];
+ };
+ const demographicsFact = new Fact("demographics", demographicsDefinition);
+ const demographicsDataFact = new Fact(
+ "demographic-data",
+ demographicsDataDefinition,
+ );
+
+ engine = engineFactory();
+ const rule = ruleFactory({ conditions, event });
+ engine.addRule(rule);
+ engine.addFact(demographicsFact);
+ engine.addFact(demographicsDataFact);
+ engine.on("success", eventSpy);
+ });
+
+ describe("1 rule", () => {
+ it("allows a fact to retrieve other fact values", async () => {
+ await engine.run();
+ expect(eventSpy).toHaveBeenCalledOnce();
+ expect(demographicDataSpy).toHaveBeenCalledOnce();
+ expect(demographicSpy).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe("2 rules with parallel conditions", () => {
+ it("calls the fact definition once", async () => {
+ const conditions = {
+ all: [
+ {
+ fact: "demographics",
+ params: {
+ field: "age",
+ },
+ operator: "greaterThanInclusive",
+ value: 20,
+ },
+ ],
+ };
+ const rule = ruleFactory({ conditions, event });
+ engine.addRule(rule);
+
+ await engine.run();
+ expect(eventSpy).toHaveBeenCalledTimes(2);
+ expect(demographicDataSpy).toHaveBeenCalledOnce();
+ expect(demographicSpy).toHaveBeenCalledTimes(2);
+ expect(demographicDataSpy).toHaveBeenCalledOnce();
+ });
+ });
+});
diff --git a/test/engine-failure.test.js b/test/engine-failure.test.js
deleted file mode 100644
index 86b3f236..00000000
--- a/test/engine-failure.test.js
+++ /dev/null
@@ -1,45 +0,0 @@
-'use strict'
-
-import engineFactory from '../src/index'
-import sinon from 'sinon'
-
-describe('Engine: failure', () => {
- let engine
- let sandbox
- before(() => {
- sandbox = sinon.createSandbox()
- })
- afterEach(() => {
- sandbox.restore()
- })
-
- const event = { type: 'generic' }
- const conditions = {
- any: [{
- fact: 'age',
- operator: 'greaterThanInclusive',
- value: 21
- }]
- }
- beforeEach(() => {
- engine = engineFactory()
- const determineDrinkingAgeRule = factories.rule({ conditions, event })
- engine.addRule(determineDrinkingAgeRule)
- engine.addFact('age', 10)
- })
-
- it('emits an event on a rule failing', async () => {
- const failureSpy = sandbox.spy()
- engine.on('failure', failureSpy)
- await engine.run()
- expect(failureSpy).to.have.been.calledWith(engine.rules[0].ruleEvent)
- })
-
- it('does not emit when a rule passes', async () => {
- const failureSpy = sandbox.spy()
- engine.on('failure', failureSpy)
- engine.addFact('age', 50)
- await engine.run()
- expect(failureSpy).to.not.have.been.calledOnce()
- })
-})
diff --git a/test/engine-failure.test.mjs b/test/engine-failure.test.mjs
new file mode 100644
index 00000000..934d4198
--- /dev/null
+++ b/test/engine-failure.test.mjs
@@ -0,0 +1,44 @@
+import engineFactory from "../src/index.mjs";
+
+import { describe, it, beforeEach, expect, vi } from "vitest";
+import ruleFactory from "./support/rule-factory.mjs";
+
+describe("Engine: failure", () => {
+ let engine;
+
+ const event = { type: "generic" };
+ const conditions = {
+ any: [
+ {
+ fact: "age",
+ operator: "greaterThanInclusive",
+ value: 21,
+ },
+ ],
+ };
+ beforeEach(() => {
+ engine = engineFactory();
+ const determineDrinkingAgeRule = ruleFactory({ conditions, event });
+ engine.addRule(determineDrinkingAgeRule);
+ engine.addFact("age", 10);
+ });
+
+ it("emits an event on a rule failing", async () => {
+ const failureSpy = vi.fn();
+ engine.on("failure", failureSpy);
+ await engine.run();
+ expect(failureSpy).toHaveBeenCalledWith(
+ engine.rules[0].ruleEvent,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it("does not emit when a rule passes", async () => {
+ const failureSpy = vi.fn();
+ engine.on("failure", failureSpy);
+ engine.addFact("age", 50);
+ await engine.run();
+ expect(failureSpy).not.toHaveBeenCalledOnce();
+ });
+});
diff --git a/test/engine-not.test.js b/test/engine-not.test.js
deleted file mode 100644
index f83bed8a..00000000
--- a/test/engine-not.test.js
+++ /dev/null
@@ -1,54 +0,0 @@
-'use strict'
-
-import sinon from 'sinon'
-import engineFactory from '../src/index'
-
-describe('Engine: "not" conditions', () => {
- let engine
- let sandbox
- before(() => {
- sandbox = sinon.createSandbox()
- })
- afterEach(() => {
- sandbox.restore()
- })
-
- describe('supports a single "not" condition', () => {
- const event = {
- type: 'ageTrigger',
- params: {
- demographic: 'under50'
- }
- }
- const conditions = {
- not: {
- fact: 'age',
- operator: 'greaterThanInclusive',
- value: 50
- }
- }
- let eventSpy
- let ageSpy
- beforeEach(() => {
- eventSpy = sandbox.spy()
- ageSpy = sandbox.stub()
- const rule = factories.rule({ conditions, event })
- engine = engineFactory()
- engine.addRule(rule)
- engine.addFact('age', ageSpy)
- engine.on('success', eventSpy)
- })
-
- it('emits when the condition is met', async () => {
- ageSpy.returns(10)
- await engine.run()
- expect(eventSpy).to.have.been.calledWith(event)
- })
-
- it('does not emit when the condition fails', () => {
- ageSpy.returns(75)
- engine.run()
- expect(eventSpy).to.not.have.been.calledWith(event)
- })
- })
-})
diff --git a/test/engine-not.test.mjs b/test/engine-not.test.mjs
new file mode 100644
index 00000000..06ee39a1
--- /dev/null
+++ b/test/engine-not.test.mjs
@@ -0,0 +1,54 @@
+import engineFactory from "../src/index.mjs";
+import { describe, it, beforeEach, expect, vi } from "vitest";
+import ruleFactory from "./support/rule-factory.mjs";
+
+describe('Engine: "not" conditions', () => {
+ let engine;
+
+ describe('supports a single "not" condition', () => {
+ const event = {
+ type: "ageTrigger",
+ params: {
+ demographic: "under50",
+ },
+ };
+ const conditions = {
+ not: {
+ fact: "age",
+ operator: "greaterThanInclusive",
+ value: 50,
+ },
+ };
+ let eventSpy;
+ let ageSpy;
+ beforeEach(() => {
+ eventSpy = vi.fn();
+ ageSpy = vi.fn();
+ const rule = ruleFactory({ conditions, event });
+ engine = engineFactory();
+ engine.addRule(rule);
+ engine.addFact("age", ageSpy);
+ engine.on("success", eventSpy);
+ });
+
+ it("emits when the condition is met", async () => {
+ ageSpy.mockReturnValue(10);
+ await engine.run();
+ expect(eventSpy).toHaveBeenCalledWith(
+ event,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it("does not emit when the condition fails", () => {
+ ageSpy.mockReturnValue(75);
+ engine.run();
+ expect(eventSpy).not.toHaveBeenCalledWith(
+ event,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+ });
+});
diff --git a/test/engine-operator-map.test.js b/test/engine-operator-map.test.js
deleted file mode 100644
index b5ae9672..00000000
--- a/test/engine-operator-map.test.js
+++ /dev/null
@@ -1,86 +0,0 @@
-'use strict'
-
-import { expect } from 'chai'
-import engineFactory, { Operator, OperatorDecorator } from '../src/index'
-
-const startsWithLetter = new Operator('startsWithLetter', (factValue, jsonValue) => {
- return factValue[0] === jsonValue
-})
-
-const never = new OperatorDecorator('never', () => false)
-
-describe('Engine Operator Map', () => {
- let engine
- beforeEach(() => {
- engine = engineFactory()
- engine.addOperator(startsWithLetter)
- engine.addOperatorDecorator(never)
- })
-
- describe('undecorated operator', () => {
- let op
- beforeEach(() => {
- op = engine.operators.get('startsWithLetter')
- })
-
- it('has the operator', () => {
- expect(op).not.to.be.null()
- })
-
- it('the operator evaluates correctly', () => {
- expect(op.evaluate('test', 't')).to.be.true()
- })
-
- it('after being removed the operator is null', () => {
- engine.operators.removeOperator(startsWithLetter)
- op = engine.operators.get('startsWithLetter')
- expect(op).to.be.null()
- })
- })
-
- describe('decorated operator', () => {
- let op
- beforeEach(() => {
- op = engine.operators.get('never:startsWithLetter')
- })
-
- it('has the operator', () => {
- expect(op).not.to.be.null()
- })
-
- it('the operator evaluates correctly', () => {
- expect(op.evaluate('test', 't')).to.be.false()
- })
-
- it('removing the base operator removes the decorated version', () => {
- engine.operators.removeOperator(startsWithLetter)
- op = engine.operators.get('never:startsWithLetter')
- expect(op).to.be.null()
- })
-
- it('removing the decorator removes the decorated operator', () => {
- engine.operators.removeOperatorDecorator(never)
- op = engine.operators.get('never:startsWithLetter')
- expect(op).to.be.null()
- })
- })
-
- describe('combinatorics with default operators', () => {
- it('combines every, some, not, and greaterThanInclusive operators', () => {
- const odds = [1, 3, 5, 7]
- const evens = [2, 4, 6, 8]
-
- // technically not:greaterThanInclusive is the same as lessThan
- const op = engine.operators.get('everyFact:someValue:not:greaterThanInclusive')
- expect(op.evaluate(odds, evens)).to.be.true()
- })
- })
-
- it('the swap decorator', () => {
- const factValue = 1
- const jsonValue = [1, 2, 3]
-
- const op = engine.operators.get('swap:contains')
- expect(op.evaluate(factValue, jsonValue)).to.be.true()
- })
-})
diff --git a/test/engine-operator-map.test.mjs b/test/engine-operator-map.test.mjs
new file mode 100644
index 00000000..c4aeb2a5
--- /dev/null
+++ b/test/engine-operator-map.test.mjs
@@ -0,0 +1,89 @@
+import engineFactory, { Operator, OperatorDecorator } from "../src/index.mjs";
+import { describe, it, beforeEach, expect } from "vitest";
+
+const startsWithLetter = new Operator(
+ "startsWithLetter",
+ (factValue, jsonValue) => {
+ return factValue[0] === jsonValue;
+ },
+);
+
+const never = new OperatorDecorator("never", () => false);
+
+describe("Engine Operator Map", () => {
+ let engine;
+ beforeEach(() => {
+ engine = engineFactory();
+ engine.addOperator(startsWithLetter);
+ engine.addOperatorDecorator(never);
+ });
+
+ describe("undecorated operator", () => {
+ let op;
+ beforeEach(() => {
+ op = engine.operators.get("startsWithLetter");
+ });
+
+ it("has the operator", () => {
+ expect(op).not.toBeNull();
+ });
+
+ it("the operator evaluates correctly", () => {
+ expect(op.evaluate("test", "t")).toBe(true);
+ });
+
+ it("after being removed the operator is null", () => {
+ engine.operators.removeOperator(startsWithLetter);
+ op = engine.operators.get("startsWithLetter");
+ expect(op).toBeNull();
+ });
+ });
+
+ describe("decorated operator", () => {
+ let op;
+ beforeEach(() => {
+ op = engine.operators.get("never:startsWithLetter");
+ });
+
+ it("has the operator", () => {
+ expect(op).not.toBeNull();
+ });
+
+ it("the operator evaluates correctly", () => {
+ expect(op.evaluate("test", "t")).toBe(false);
+ });
+
+ it("removing the base operator removes the decorated version", () => {
+ engine.operators.removeOperator(startsWithLetter);
+ op = engine.operators.get("never:startsWithLetter");
+ expect(op).toBeNull();
+ });
+
+ it("removing the decorator removes the decorated operator", () => {
+ engine.operators.removeOperatorDecorator(never);
+ op = engine.operators.get("never:startsWithLetter");
+ expect(op).toBeNull();
+ });
+ });
+
+ describe("combinatorics with default operators", () => {
+ it("combines every, some, not, and greaterThanInclusive operators", () => {
+ const odds = [1, 3, 5, 7];
+ const evens = [2, 4, 6, 8];
+
+ // technically not:greaterThanInclusive is the same as lessThan
+ const op = engine.operators.get(
+ "everyFact:someValue:not:greaterThanInclusive",
+ );
+ expect(op.evaluate(odds, evens)).toBe(true);
+ });
+ });
+
+ it("the swap decorator", () => {
+ const factValue = 1;
+ const jsonValue = [1, 2, 3];
+
+ const op = engine.operators.get("swap:contains");
+ expect(op.evaluate(factValue, jsonValue)).toBe(true);
+ });
+});
diff --git a/test/engine-operator.test.js b/test/engine-operator.test.js
deleted file mode 100644
index 03d04bcd..00000000
--- a/test/engine-operator.test.js
+++ /dev/null
@@ -1,71 +0,0 @@
-'use strict'
-
-import sinon from 'sinon'
-import engineFactory from '../src/index'
-
-async function dictionary (params, engine) {
- const words = ['coffee', 'Aardvark', 'moose', 'ladder', 'antelope']
- return words[params.wordIndex]
-}
-
-describe('Engine: operator', () => {
- let sandbox
- before(() => {
- sandbox = sinon.createSandbox()
- })
- afterEach(() => {
- sandbox.restore()
- })
- const event = {
- type: 'operatorTrigger'
- }
- const baseConditions = {
- any: [{
- fact: 'dictionary',
- operator: 'startsWithLetter',
- value: 'a',
- params: {
- wordIndex: null
- }
- }]
- }
- let eventSpy
- function setup (conditions = baseConditions) {
- eventSpy = sandbox.spy()
- const engine = engineFactory()
- const rule = factories.rule({ conditions, event })
- engine.addRule(rule)
- engine.addOperator('startsWithLetter', (factValue, jsonValue) => {
- if (!factValue.length) return false
- return factValue[0].toLowerCase() === jsonValue.toLowerCase()
- })
- engine.addFact('dictionary', dictionary)
- engine.on('success', eventSpy)
- return engine
- }
-
- describe('evaluation', () => {
- it('emits when the condition is met', async () => {
- const conditions = Object.assign({}, baseConditions)
- conditions.any[0].params.wordIndex = 1
- const engine = setup()
- await engine.run()
- expect(eventSpy).to.have.been.calledWith(event)
- })
-
- it('does not emit when the condition fails', async () => {
- const conditions = Object.assign({}, baseConditions)
- conditions.any[0].params.wordIndex = 0
- const engine = setup()
- await engine.run()
- expect(eventSpy).to.not.have.been.calledWith(event)
- })
-
- it('throws when it encounters an unregistered operator', async () => {
- const conditions = Object.assign({}, baseConditions)
- conditions.any[0].operator = 'unknown-operator'
- const engine = setup()
- return expect(engine.run()).to.eventually.be.rejectedWith('Unknown operator: unknown-operator')
- })
- })
-})
diff --git a/test/engine-operator.test.mjs b/test/engine-operator.test.mjs
new file mode 100644
index 00000000..1e200a49
--- /dev/null
+++ b/test/engine-operator.test.mjs
@@ -0,0 +1,71 @@
+import engineFactory from "../src/index.mjs";
+import { describe, it, expect, vi } from "vitest";
+import ruleFactory from "./support/rule-factory.mjs";
+
+async function dictionary(params) {
+ const words = ["coffee", "Aardvark", "moose", "ladder", "antelope"];
+ return words[params.wordIndex];
+}
+
+describe("Engine: operator", () => {
+ const event = {
+ type: "operatorTrigger",
+ };
+ const baseConditions = {
+ any: [
+ {
+ fact: "dictionary",
+ operator: "startsWithLetter",
+ value: "a",
+ params: {
+ wordIndex: null,
+ },
+ },
+ ],
+ };
+ let eventSpy;
+ function setup(conditions = baseConditions) {
+ eventSpy = vi.fn();
+ const engine = engineFactory();
+ const rule = ruleFactory({ conditions, event });
+ engine.addRule(rule);
+ engine.addOperator("startsWithLetter", (factValue, jsonValue) => {
+ if (!factValue.length) return false;
+ return factValue[0].toLowerCase() === jsonValue.toLowerCase();
+ });
+ engine.addFact("dictionary", dictionary);
+ engine.on("success", eventSpy);
+ return engine;
+ }
+
+ describe("evaluation", () => {
+ it("emits when the condition is met", async () => {
+ const conditions = Object.assign({}, baseConditions);
+ conditions.any[0].params.wordIndex = 1;
+ const engine = setup();
+ await engine.run();
+ expect(eventSpy).toHaveBeenCalledWith(
+ event,
+ expect.anything(),
+ expect.anything(),
+ );
+ });
+
+ it("does not emit when the condition fails", async () => {
+ const conditions = Object.assign({}, baseConditions);
+ conditions.any[0].params.wordIndex = 0;
+ const engine = setup();
+ await engine.run();
+ expect(eventSpy).not.toHaveBeenCalled();
+ });
+
+ it("throws when it encounters an unregistered operator", async () => {
+ const conditions = Object.assign({}, baseConditions);
+ conditions.any[0].operator = "unknown-operator";
+ const engine = setup();
+ return expect(engine.run()).rejects.toThrow(
+ "Unknown operator: unknown-operator",
+ );
+ });
+ });
+});
diff --git a/test/engine-parallel-condition-cache.test.js b/test/engine-parallel-condition-cache.test.js
deleted file mode 100644
index 5bb86e8a..00000000
--- a/test/engine-parallel-condition-cache.test.js
+++ /dev/null
@@ -1,84 +0,0 @@
-'use strict'
-
-import engineFactory from '../src/index'
-import sinon from 'sinon'
-
-describe('Engine', () => {
- let engine
- let sandbox
- before(() => {
- sandbox = sinon.createSandbox()
- })
- afterEach(() => {
- sandbox.restore()
- })
- const event = { type: 'early-twenties' }
- const conditions = {
- all: [{
- fact: 'age',
- operator: 'lessThanInclusive',
- value: 25
- }, {
- fact: 'age',
- operator: 'greaterThanInclusive',
- value: 20
- }, {
- fact: 'age',
- operator: 'notIn',
- value: [21, 22]
- }]
- }
-
- let eventSpy
- let factSpy
- function setup (factOptions) {
- factSpy = sandbox.spy()
- eventSpy = sandbox.spy()
-
- const factDefinition = () => {
- factSpy()
- return 24
- }
-
- engine = engineFactory()
- const rule = factories.rule({ conditions, event })
- engine.addRule(rule)
- engine.addFact('age', factDefinition, factOptions)
- engine.on('success', eventSpy)
- }
-
- describe('1 rule with parallel conditions', () => {
- it('calls the fact definition once for each condition if caching is off', async () => {
- setup({ cache: false })
- await engine.run()
- expect(eventSpy).to.have.been.calledOnce()
- expect(factSpy).to.have.been.calledThrice()
- })
-
- it('calls the fact definition once', async () => {
- setup()
- await engine.run()
- expect(eventSpy).to.have.been.calledOnce()
- expect(factSpy).to.have.been.calledOnce()
- })
- })
-
- describe('2 rules with parallel conditions', () => {
- it('calls the fact definition once', async () => {
- setup()
- const conditions = {
- all: [{
- fact: 'age',
- operator: 'notIn',
- value: [21, 22]
- }]
- }
- const rule = factories.rule({ conditions, event })
- engine.addRule(rule)
-
- await engine.run()
- expect(eventSpy).to.have.been.calledTwice()
- expect(factSpy).to.have.been.calledOnce()
- })
- })
-})
diff --git a/test/engine-parallel-condition-cache.test.mjs b/test/engine-parallel-condition-cache.test.mjs
new file mode 100644
index 00000000..0c9ae8f7
--- /dev/null
+++ b/test/engine-parallel-condition-cache.test.mjs
@@ -0,0 +1,84 @@
+import engineFactory from "../src/index.mjs";
+
+import { describe, it, expect, vi } from "vitest";
+import ruleFactory from "./support/rule-factory.mjs";
+
+describe("Engine", () => {
+ let engine;
+
+ const event = { type: "early-twenties" };
+ const conditions = {
+ all: [
+ {
+ fact: "age",
+ operator: "lessThanInclusive",
+ value: 25,
+ },
+ {
+ fact: "age",
+ operator: "greaterThanInclusive",
+ value: 20,
+ },
+ {
+ fact: "age",
+ operator: "notIn",
+ value: [21, 22],
+ },
+ ],
+ };
+
+ let eventSpy;
+ let factSpy;
+ function setup(factOptions) {
+ factSpy = vi.fn();
+ eventSpy = vi.fn();
+
+ const factDefinition = () => {
+ factSpy();
+ return 24;
+ };
+
+ engine = engineFactory();
+ const rule = ruleFactory({ conditions, event });
+ engine.addRule(rule);
+ engine.addFact("age", factDefinition, factOptions);
+ engine.on("success", eventSpy);
+ }
+
+ describe("1 rule with parallel conditions", () => {
+ it("calls the fact definition once for each condition if caching is off", async () => {
+ setup({ cache: false });
+ await engine.run();
+ expect(eventSpy).toHaveBeenCalledOnce();
+ expect(factSpy).toHaveBeenCalledTimes(3);
+ });
+
+ it("calls the fact definition once", async () => {
+ setup();
+ await engine.run();
+ expect(eventSpy).toHaveBeenCalledOnce();
+ expect(factSpy).toHaveBeenCalledOnce();
+ });
+ });
+
+ describe("2 rules with parallel conditions", () => {
+ it("calls the fact definition once", async () => {
+ setup();
+ const conditions = {
+ all: [
+ {
+ fact: "age",
+ operator: "notIn",
+ value: [21, 22],
+ },
+ ],
+ };
+ const rule = ruleFactory({ conditions, event });
+ engine.addRule(rule);
+
+ await engine.run();
+ expect(eventSpy).toHaveBeenCalledTimes(2);
+ expect(factSpy).toHaveBeenCalledOnce();
+ });
+ });
+});
diff --git a/test/engine-recusive-rules.test.js b/test/engine-recusive-rules.test.js
deleted file mode 100644
index 48935998..00000000
--- a/test/engine-recusive-rules.test.js
+++ /dev/null
@@ -1,233 +0,0 @@
-'use strict'
-
-import engineFactory from '../src/index'
-import sinon from 'sinon'
-
-describe('Engine: recursive rules', () => {
- let engine
- const event = { type: 'middle-income-adult' }
- const nestedAnyCondition = {
- all: [
- {
- fact: 'age',
- operator: 'lessThan',
- value: 65
- },
- {
- fact: 'age',
- operator: 'greaterThan',
- value: 21
- },
- {
- any: [
- {
- fact: 'income',
- operator: 'lessThanInclusive',
- value: 100
- },
- {
- fact: 'family-size',
- operator: 'lessThanInclusive',
- value: 3
- }
- ]
- }
- ]
- }
-
- let sandbox
- before(() => {
- sandbox = sinon.createSandbox()
- })
- afterEach(() => {
- sandbox.restore()
- })
-
- let eventSpy
- function setup (conditions = nestedAnyCondition) {
- eventSpy = sandbox.spy()
-
- engine = engineFactory()
- const rule = factories.rule({ conditions, event })
- engine.addRule(rule)
- engine.on('success', eventSpy)
- }
-
- describe('"all" with nested "any"', () => {
- it('evaluates true when facts pass rules', async () => {
- setup()
- engine.addFact('age', 30)
- engine.addFact('income', 30)
- engine.addFact('family-size', 2)
- await engine.run()
- expect(eventSpy).to.have.been.calledOnce()
- })
-
- it('evaluates false when facts do not pass rules', async () => {
- setup()
- engine.addFact('age', 30)
- engine.addFact('income', 200)
- engine.addFact('family-size', 8)
- await engine.run()
- expect(eventSpy).to.not.have.been.calledOnce()
- })
- })
-
- const nestedAllCondition = {
- any: [
- {
- fact: 'age',
- operator: 'lessThan',
- value: 65
- },
- {
- fact: 'age',
- operator: 'equal',
- value: 70
- },
- {
- all: [
- {
- fact: 'income',
- operator: 'lessThanInclusive',
- value: 100
- },
- {
- fact: 'family-size',
- operator: 'lessThanInclusive',
- value: 3
- }
- ]
- }
- ]
- }
-
- describe('"any" with nested "all"', () => {
- it('evaluates true when facts pass rules', async () => {
- setup(nestedAllCondition)
- engine.addFact('age', 90)
- engine.addFact('income', 30)
- engine.addFact('family-size', 2)
- await engine.run()
- expect(eventSpy).to.have.been.calledOnce()
- })
-
- it('evaluates false when facts do not pass rules', async () => {
- setup(nestedAllCondition)
- engine.addFact('age', 90)
- engine.addFact('income', 200)
- engine.addFact('family-size', 2)
- await engine.run()
- expect(eventSpy).to.not.have.been.calledOnce()
- })
- })
-
- const thriceNestedCondition = {
- any: [
- {
- all: [
- {
- any: [
- {
- fact: 'income',
- operator: 'lessThanInclusive',
- value: 100
- }
- ]
- },
- {
- fact: 'family-size',
- operator: 'lessThanInclusive',
- value: 3
- }
- ]
- }
- ]
- }
-
- describe('"any" with "all" within "any"', () => {
- it('evaluates true when facts pass rules', async () => {
- setup(thriceNestedCondition)
- engine.addFact('income', 30)
- engine.addFact('family-size', 1)
- await engine.run()
- expect(eventSpy).to.have.been.calledOnce()
- })
-
- it('evaluates false when facts do not pass rules', async () => {
- setup(thriceNestedCondition)
- engine.addFact('income', 30)
- engine.addFact('family-size', 5)
- await engine.run()
- expect(eventSpy).to.not.have.been.calledOnce()
- })
- })
-
- const notNotCondition = {
- not: {
- not: {
- fact: 'age',
- operator: 'lessThan',
- value: 65
- }
- }
- }
-
- describe('"not" nested directly within a "not"', () => {
- it('evaluates true when facts pass rules', async () => {
- setup(notNotCondition)
- engine.addFact('age', 30)
- await engine.run()
- expect(eventSpy).to.have.been.calledOnce()
- })
-
- it('evaluates false when facts do not pass rules', async () => {
- setup(notNotCondition)
- engine.addFact('age', 65)
- await engine.run()
- expect(eventSpy).to.not.have.been.calledOnce()
- })
- })
-
- const nestedNotCondition = {
- not: {
- all: [
- {
- fact: 'age',
- operator: 'lessThan',
- value: 65
- },
- {
- fact: 'age',
- operator: 'greaterThan',
- value: 21
- },
- {
- not: {
- fact: 'income',
- operator: 'lessThanInclusive',
- value: 100
- }
- }
- ]
- }
- }
-
- describe('outer "not" with nested "all" and nested "not" condition', () => {
- it('evaluates true when facts pass rules', async () => {
- setup(nestedNotCondition)
- engine.addFact('age', 30)
- engine.addFact('income', 100)
- await engine.run()
- expect(eventSpy).to.have.been.calledOnce()
- })
-
- it('evaluates false when facts do not pass rules', async () => {
- setup(nestedNotCondition)
- engine.addFact('age', 30)
- engine.addFact('income', 101)
- await engine.run()
- expect(eventSpy).to.not.have.been.calledOnce()
- })
- })
-})
diff --git a/test/engine-recusive-rules.test.mjs b/test/engine-recusive-rules.test.mjs
new file mode 100644
index 00000000..cb744c32
--- /dev/null
+++ b/test/engine-recusive-rules.test.mjs
@@ -0,0 +1,225 @@
+import engineFactory from "../src/index.mjs";
+
+import { describe, it, expect, vi } from "vitest";
+import ruleFactory from "./support/rule-factory.mjs";
+
+describe("Engine: recursive rules", () => {
+ let engine;
+ const event = { type: "middle-income-adult" };
+ const nestedAnyCondition = {
+ all: [
+ {
+ fact: "age",
+ operator: "lessThan",
+ value: 65,
+ },
+ {
+ fact: "age",
+ operator: "greaterThan",
+ value: 21,
+ },
+ {
+ any: [
+ {
+ fact: "income",
+ operator: "lessThanInclusive",
+ value: 100,
+ },
+ {
+ fact: "family-size",
+ operator: "lessThanInclusive",
+ value: 3,
+ },
+ ],
+ },
+ ],
+ };
+
+ let eventSpy;
+ function setup(conditions = nestedAnyCondition) {
+ eventSpy = vi.fn();
+
+ engine = engineFactory();
+ const rule = ruleFactory({ conditions, event });
+ engine.addRule(rule);
+ engine.on("success", eventSpy);
+ }
+
+ describe('"all" with nested "any"', () => {
+ it("evaluates true when facts pass rules", async () => {
+ setup();
+ engine.addFact("age", 30);
+ engine.addFact("income", 30);
+ engine.addFact("family-size", 2);
+ await engine.run();
+ expect(eventSpy).toHaveBeenCalledOnce();
+ });
+
+ it("evaluates false when facts do not pass rules", async () => {
+ setup();
+ engine.addFact("age", 30);
+ engine.addFact("income", 200);
+ engine.addFact("family-size", 8);
+ await engine.run();
+ expect(eventSpy).not.toHaveBeenCalledOnce();
+ });
+ });
+
+ const nestedAllCondition = {
+ any: [
+ {
+ fact: "age",
+ operator: "lessThan",
+ value: 65,
+ },
+ {
+ fact: "age",
+ operator: "equal",
+ value: 70,
+ },
+ {
+ all: [
+ {
+ fact: "income",
+ operator: "lessThanInclusive",
+ value: 100,
+ },
+ {
+ fact: "family-size",
+ operator: "lessThanInclusive",
+ value: 3,
+ },
+ ],
+ },
+ ],
+ };
+
+ describe('"any" with nested "all"', () => {
+ it("evaluates true when facts pass rules", async () => {
+ setup(nestedAllCondition);
+ engine.addFact("age", 90);
+ engine.addFact("income", 30);
+ engine.addFact("family-size", 2);
+ await engine.run();
+ expect(eventSpy).toHaveBeenCalledOnce();
+ });
+
+ it("evaluates false when facts do not pass rules", async () => {
+ setup(nestedAllCondition);
+ engine.addFact("age", 90);
+ engine.addFact("income", 200);
+ engine.addFact("family-size", 2);
+ await engine.run();
+ expect(eventSpy).not.toHaveBeenCalledOnce();
+ });
+ });
+
+ const thriceNestedCondition = {
+ any: [
+ {
+ all: [
+ {
+ any: [
+ {
+ fact: "income",
+ operator: "lessThanInclusive",
+ value: 100,
+ },
+ ],
+ },
+ {
+ fact: "family-size",
+ operator: "lessThanInclusive",
+ value: 3,
+ },
+ ],
+ },
+ ],
+ };
+
+ describe('"any" with "all" within "any"', () => {
+ it("evaluates true when facts pass rules", async () => {
+ setup(thriceNestedCondition);
+ engine.addFact("income", 30);
+ engine.addFact("family-size", 1);
+ await engine.run();
+ expect(eventSpy).toHaveBeenCalledOnce();
+ });
+
+ it("evaluates false when facts do not pass rules", async () => {
+ setup(thriceNestedCondition);
+ engine.addFact("income", 30);
+ engine.addFact("family-size", 5);
+ await engine.run();
+ expect(eventSpy).not.toHaveBeenCalledOnce();
+ });
+ });
+
+ const notNotCondition = {
+ not: {
+ not: {
+ fact: "age",
+ operator: "lessThan",
+ value: 65,
+ },
+ },
+ };
+
+ describe('"not" nested directly within a "not"', () => {
+ it("evaluates true when facts pass rules", async () => {
+ setup(notNotCondition);
+ engine.addFact("age", 30);
+ await engine.run();
+ expect(eventSpy).toHaveBeenCalledOnce();
+ });
+
+ it("evaluates false when facts do not pass rules", async () => {
+ setup(notNotCondition);
+ engine.addFact("age", 65);
+ await engine.run();
+ expect(eventSpy).not.toHaveBeenCalledOnce();
+ });
+ });
+
+ const nestedNotCondition = {
+ not: {
+ all: [
+ {
+ fact: "age",
+ operator: "lessThan",
+ value: 65,
+ },
+ {
+ fact: "age",
+ operator: "greaterThan",
+ value: 21,
+ },
+ {
+ not: {
+ fact: "income",
+ operator: "lessThanInclusive",
+ value: 100,
+ },
+ },
+ ],
+ },
+ };
+
+ describe('outer "not" with nested "all" and nested "not" condition', () => {
+ it("evaluates true when facts pass rules", async () => {
+ setup(nestedNotCondition);
+ engine.addFact("age", 30);
+ engine.addFact("income", 100);
+ await engine.run();
+ expect(eventSpy).toHaveBeenCalledOnce();
+ });
+
+ it("evaluates false when facts do not pass rules", async () => {
+ setup(nestedNotCondition);
+ engine.addFact("age", 30);
+ engine.addFact("income", 101);
+ await engine.run();
+ expect(eventSpy).not.toHaveBeenCalledOnce();
+ });
+ });
+});
diff --git a/test/engine-rule-priority.js b/test/engine-rule-priority.js
deleted file mode 100644
index df96f995..00000000
--- a/test/engine-rule-priority.js
+++ /dev/null
@@ -1,96 +0,0 @@
-'use strict'
-
-import engineFactory from '../src/index'
-import sinon from 'sinon'
-
-describe('Engine: rule priorities', () => {
- let engine
-
- const highPriorityEvent = { type: 'highPriorityEvent' }
- const midPriorityEvent = { type: 'midPriorityEvent' }
- const lowestPriorityEvent = { type: 'lowestPriorityEvent' }
- const conditions = {
- any: [{
- fact: 'age',
- operator: 'greaterThanInclusive',
- value: 21
- }]
- }
-
- let sandbox
- before(() => {
- sandbox = sinon.createSandbox()
- })
- afterEach(() => {
- sandbox.restore()
- })
-
- function setup () {
- const factSpy = sandbox.stub().returns(22)
- const eventSpy = sandbox.spy()
- engine = engineFactory()
-
- const highPriorityRule = factories.rule({ conditions, event: midPriorityEvent, priority: 50 })
- engine.addRule(highPriorityRule)
-
- const midPriorityRule = factories.rule({ conditions, event: highPriorityEvent, priority: 100 })
- engine.addRule(midPriorityRule)
-
- const lowPriorityRule = factories.rule({ conditions, event: lowestPriorityEvent, priority: 1 })
- engine.addRule(lowPriorityRule)
-
- engine.addFact('age', factSpy)
- engine.on('success', eventSpy)
- }
-
- it('runs the rules in order of priority', () => {
- setup()
- expect(engine.prioritizedRules).to.be.null()
- engine.prioritizeRules()
- expect(engine.prioritizedRules.length).to.equal(3)
- expect(engine.prioritizedRules[0][0].priority).to.equal(100)
- expect(engine.prioritizedRules[1][0].priority).to.equal(50)
- expect(engine.prioritizedRules[2][0].priority).to.equal(1)
- })
-
- it('clears re-propriorizes the rules when a new Rule is added', () => {
- engine.prioritizeRules()
- expect(engine.prioritizedRules.length).to.equal(3)
- engine.addRule(factories.rule())
- expect(engine.prioritizedRules).to.be.null()
- })
-
- it('resolves all events returning promises before executing the next rule', async () => {
- setup()
-
- const highPrioritySpy = sandbox.spy()
- const midPrioritySpy = sandbox.spy()
- const lowPrioritySpy = sandbox.spy()
-
- engine.on(highPriorityEvent.type, () => {
- return new Promise(function (resolve) {
- setTimeout(function () {
- highPrioritySpy()
- resolve()
- }, 10) // wait longest
- })
- })
- engine.on(midPriorityEvent.type, () => {
- return new Promise(function (resolve) {
- setTimeout(function () {
- midPrioritySpy()
- resolve()
- }, 5) // wait half as much
- })
- })
-
- engine.on(lowestPriorityEvent.type, () => {
- lowPrioritySpy() // emit immediately. this event should still be triggered last
- })
-
- await engine.run()
-
- expect(highPrioritySpy).to.be.calledBefore(midPrioritySpy)
- expect(midPrioritySpy).to.be.calledBefore(lowPrioritySpy)
- })
-})
diff --git a/test/engine-rule-priority.test.mjs b/test/engine-rule-priority.test.mjs
new file mode 100644
index 00000000..c8372c91
--- /dev/null
+++ b/test/engine-rule-priority.test.mjs
@@ -0,0 +1,106 @@
+import engineFactory from "../src/index.mjs";
+
+import { describe, it, expect, vi } from "vitest";
+import ruleFactory from "./support/rule-factory.mjs";
+
+describe("Engine: rule priorities", () => {
+ let engine;
+
+ const highPriorityEvent = { type: "highPriorityEvent" };
+ const midPriorityEvent = { type: "midPriorityEvent" };
+ const lowestPriorityEvent = { type: "lowestPriorityEvent" };
+ const conditions = {
+ any: [
+ {
+ fact: "age",
+ operator: "greaterThanInclusive",
+ value: 21,
+ },
+ ],
+ };
+
+ function setup() {
+ const factSpy = vi.fn().mockReturnValue(22);
+ const eventSpy = vi.fn();
+ engine = engineFactory();
+
+ const highPriorityRule = ruleFactory({
+ conditions,
+ event: midPriorityEvent,
+ priority: 50,
+ });
+ engine.addRule(highPriorityRule);
+
+ const midPriorityRule = ruleFactory({
+ conditions,
+ event: highPriorityEvent,
+ priority: 100,
+ });
+ engine.addRule(midPriorityRule);
+
+ const lowPriorityRule = ruleFactory({
+ conditions,
+ event: lowestPriorityEvent,
+ priority: 1,
+ });
+ engine.addRule(lowPriorityRule);
+
+ engine.addFact("age", factSpy);
+ engine.on("success", eventSpy);
+ }
+
+ it("runs the rules in order of priority", () => {
+ setup();
+ expect(engine.prioritizedRules).toBeNull();
+ engine.prioritizeRules();
+ expect(engine.prioritizedRules.length).toBe(3);
+ expect(engine.prioritizedRules[0][0].priority).toBe(100);
+ expect(engine.prioritizedRules[1][0].priority).toBe(50);
+ expect(engine.prioritizedRules[2][0].priority).toBe(1);
+ });
+
+ it("clears re-propriorizes the rules when a new Rule is added", () => {
+ engine.prioritizeRules();
+ expect(engine.prioritizedRules.length).toBe(3);
+ engine.addRule(ruleFactory());
+ expect(engine.prioritizedRules).toBeNull();
+ });
+
+ it("resolves all events returning promises before executing the next rule", async () => {
+ setup();
+
+ const highPrioritySpy = vi.fn();
+ const midPrioritySpy = vi.fn();
+ const lowPrioritySpy = vi.fn();
+
+ engine.on(highPriorityEvent.type, () => {
+ return new Promise(function (resolve) {
+ setTimeout(function () {
+ highPrioritySpy();
+ resolve();
+ }, 10); // wait longest
+ });
+ });
+ engine.on(midPriorityEvent.type, () => {
+ return new Promise(function (resolve) {
+ setTimeout(function () {
+ midPrioritySpy();
+ resolve();
+ }, 5); // wait half as much
+ });
+ });
+
+ engine.on(lowestPriorityEvent.type, () => {
+ lowPrioritySpy(); // emit immediately. this event should still be triggered last
+ });
+
+ await engine.run();
+
+ expect(Math.min(...highPrioritySpy.mock.invocationCallOrder)).toBeLessThan(
+ Math.min(...midPrioritySpy.mock.invocationCallOrder),
+ );
+ expect(Math.min(...midPrioritySpy.mock.invocationCallOrder)).toBeLessThan(
+ Math.min(...lowPrioritySpy.mock.invocationCallOrder),
+ );
+ });
+});
diff --git a/test/engine-run.test.js b/test/engine-run.test.js
deleted file mode 100644
index a96d950a..00000000
--- a/test/engine-run.test.js
+++ /dev/null
@@ -1,136 +0,0 @@
-'use strict'
-
-import engineFactory from '../src/index'
-import Almanac from '../src/almanac'
-import sinon from 'sinon'
-
-describe('Engine: run', () => {
- let engine, rule, rule2
- let sandbox
- before(() => {
- sandbox = sinon.createSandbox()
- })
- afterEach(() => {
- sandbox.restore()
- })
-
- const condition21 = {
- any: [{
- fact: 'age',
- operator: 'greaterThanInclusive',
- value: 21
- }]
- }
- const condition75 = {
- any: [{
- fact: 'age',
- operator: 'greaterThanInclusive',
- value: 75
- }]
- }
- let eventSpy
-
- beforeEach(() => {
- eventSpy = sandbox.spy()
- engine = engineFactory()
- rule = factories.rule({ conditions: condition21, event: { type: 'generic1' } })
- engine.addRule(rule)
- rule2 = factories.rule({ conditions: condition75, event: { type: 'generic2' } })
- engine.addRule(rule2)
- engine.on('success', eventSpy)
- })
-
- describe('independent runs', () => {
- it('treats each run() independently', async () => {
- await Promise.all([50, 10, 12, 30, 14, 15, 25].map((age) => engine.run({ age })))
- expect(eventSpy).to.have.been.calledThrice()
- })
-
- it('allows runtime facts to override engine facts for a single run()', async () => {
- engine.addFact('age', 30)
-
- await engine.run({ age: 85 }) // override 'age' with runtime fact
- expect(eventSpy).to.have.been.calledTwice()
-
- sandbox.reset()
- await engine.run() // no runtime fact; revert to age: 30
- expect(eventSpy).to.have.been.calledOnce()
-
- sandbox.reset()
- await engine.run({ age: 2 }) // override 'age' with runtime fact
- expect(eventSpy.callCount).to.equal(0)
- })
- })
-
- describe('returns', () => {
- it('activated events', async () => {
- const { events, failureEvents } = await engine.run({ age: 30 })
- expect(events.length).to.equal(1)
- expect(events).to.deep.include(rule.event)
- expect(failureEvents.length).to.equal(1)
- expect(failureEvents).to.deep.include(rule2.event)
- })
-
- it('multiple activated events', () => {
- return engine.run({ age: 90 }).then(results => {
- expect(results.events.length).to.equal(2)
- expect(results.events).to.deep.include(rule.event)
- expect(results.events).to.deep.include(rule2.event)
- })
- })
-
- it('does not include unactived triggers', () => {
- return engine.run({ age: 10 }).then(results => {
- expect(results.events.length).to.equal(0)
- })
- })
-
- it('includes the almanac', () => {
- return engine.run({ age: 10 }).then(results => {
- expect(results.almanac).to.be.an.instanceOf(Almanac)
- return results.almanac.factValue('age')
- }).then(ageFact => expect(ageFact).to.equal(10))
- })
- })
-
- describe('facts updated during run', () => {
- beforeEach(() => {
- engine.on('success', (event, almanac, ruleResult) => {
- // Assign unique runtime facts per event
- almanac.addRuntimeFact(`runtime-fact-${event.type}`, ruleResult.conditions.any[0].value)
- })
- })
-
- it('returns an almanac with runtime facts added', () => {
- return engine.run({ age: 90 }).then(results => {
- return Promise.all([
- results.almanac.factValue('runtime-fact-generic1'),
- results.almanac.factValue('runtime-fact-generic2')
- ])
- }).then(promiseValues => {
- expect(promiseValues[0]).to.equal(21)
- expect(promiseValues[1]).to.equal(75)
- })
- })
- })
-
- describe('custom alamanc', () => {
- class CapitalAlmanac extends Almanac {
- factValue (factId, params, path) {
- return super.factValue(factId, params, path).then(value => {
- if (typeof value === 'string') {
- return value.toUpperCase()
- }
- return value
- })
- }
- }
-
- it('returns the capitalized value when using the CapitalAlamanc', () => {
- return engine.run({ greeting: 'hello', age: 30 }, { almanac: new CapitalAlmanac() }).then((results) => {
- const fact = results.almanac.factValue('greeting')
- return expect(fact).to.eventually.equal('HELLO')
- })
- })
- })
-})
diff --git a/test/engine-run.test.mjs b/test/engine-run.test.mjs
new file mode 100644
index 00000000..b2f5064c
--- /dev/null
+++ b/test/engine-run.test.mjs
@@ -0,0 +1,152 @@
+import engineFactory from "../src/index.mjs";
+import Almanac from "../src/almanac.mjs";
+
+import { describe, it, beforeEach, expect, vi } from "vitest";
+import ruleFactory from "./support/rule-factory.mjs";
+
+describe("Engine: run", () => {
+ let engine, rule, rule2;
+
+ const condition21 = {
+ any: [
+ {
+ fact: "age",
+ operator: "greaterThanInclusive",
+ value: 21,
+ },
+ ],
+ };
+ const condition75 = {
+ any: [
+ {
+ fact: "age",
+ operator: "greaterThanInclusive",
+ value: 75,
+ },
+ ],
+ };
+ let eventSpy;
+
+ beforeEach(() => {
+ eventSpy = vi.fn();
+ engine = engineFactory();
+ rule = ruleFactory({
+ conditions: condition21,
+ event: { type: "generic1" },
+ });
+ engine.addRule(rule);
+ rule2 = ruleFactory({
+ conditions: condition75,
+ event: { type: "generic2" },
+ });
+ engine.addRule(rule2);
+ engine.on("success", eventSpy);
+ });
+
+ describe("independent runs", () => {
+ it("treats each run() independently", async () => {
+ await Promise.all(
+ [50, 10, 12, 30, 14, 15, 25].map((age) => engine.run({ age })),
+ );
+ expect(eventSpy).toHaveBeenCalledTimes(3);
+ });
+
+ it("allows runtime facts to override engine facts for a single run()", async () => {
+ engine.addFact("age", 30);
+
+ await engine.run({ age: 85 }); // override 'age' with runtime fact
+ expect(eventSpy).toHaveBeenCalledTimes(2);
+
+ eventSpy.mockReset();
+ await engine.run(); // no runtime fact; revert to age: 30
+ expect(eventSpy).toHaveBeenCalledOnce();
+
+ eventSpy.mockReset();
+ await engine.run({ age: 2 }); // override 'age' with runtime fact
+ expect(eventSpy).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("returns", () => {
+ it("activated events", async () => {
+ const { events, failureEvents } = await engine.run({ age: 30 });
+ expect(events.length).toBe(1);
+ expect(events).toContainEqual(rule.event);
+ expect(failureEvents.length).toBe(1);
+ expect(failureEvents).toContainEqual(rule2.event);
+ });
+
+ it("multiple activated events", () => {
+ return engine.run({ age: 90 }).then((results) => {
+ expect(results.events.length).toBe(2);
+ expect(results.events).toContainEqual(rule.event);
+ expect(results.events).toContainEqual(rule2.event);
+ });
+ });
+
+ it("does not include unactived triggers", () => {
+ return engine.run({ age: 10 }).then((results) => {
+ expect(results.events.length).toBe(0);
+ });
+ });
+
+ it("includes the almanac", () => {
+ return engine
+ .run({ age: 10 })
+ .then((results) => {
+ expect(results.almanac).toBeInstanceOf(Almanac);
+ return results.almanac.factValue("age");
+ })
+ .then((ageFact) => expect(ageFact).toBe(10));
+ });
+ });
+
+ describe("facts updated during run", () => {
+ beforeEach(() => {
+ engine.on("success", (event, almanac, ruleResult) => {
+ // Assign unique runtime facts per event
+ almanac.addRuntimeFact(
+ `runtime-fact-${event.type}`,
+ ruleResult.conditions.any[0].value,
+ );
+ });
+ });
+
+ it("returns an almanac with runtime facts added", () => {
+ return engine
+ .run({ age: 90 })
+ .then((results) => {
+ return Promise.all([
+ results.almanac.factValue("runtime-fact-generic1"),
+ results.almanac.factValue("runtime-fact-generic2"),
+ ]);
+ })
+ .then((promiseValues) => {
+ expect(promiseValues[0]).toBe(21);
+ expect(promiseValues[1]).toBe(75);
+ });
+ });
+ });
+
+ describe("custom alamanc", () => {
+ class CapitalAlmanac extends Almanac {
+ factValue(factId, params, path) {
+ return super.factValue(factId, params, path).then((value) => {
+ if (typeof value === "string") {
+ return value.toUpperCase();
+ }
+ return value;
+ });
+ }
+ }
+
+ it("returns the capitalized value when using the CapitalAlamanc", () => {
+ return engine
+ .run({ greeting: "hello", age: 30 }, { almanac: new CapitalAlmanac() })
+ .then((results) => {
+ const fact = results.almanac.factValue("greeting");
+ return expect(fact).resolves.toBe("HELLO");
+ });
+ });
+ });
+});
diff --git a/test/engine.test.js b/test/engine.test.js
deleted file mode 100644
index 8ad3ca74..00000000
--- a/test/engine.test.js
+++ /dev/null
@@ -1,330 +0,0 @@
-'use strict'
-
-import sinon from 'sinon'
-import engineFactory, { Fact, Rule, Operator } from '../src/index'
-import defaultOperators from '../src/engine-default-operators'
-
-describe('Engine', () => {
- let engine
- let sandbox
- before(() => {
- sandbox = sinon.createSandbox()
- })
- afterEach(() => {
- sandbox.restore()
- })
- beforeEach(() => {
- engine = engineFactory()
- })
-
- it('has methods for managing facts and rules, and running itself', () => {
- expect(engine).to.have.property('addRule')
- expect(engine).to.have.property('removeRule')
- expect(engine).to.have.property('addOperator')
- expect(engine).to.have.property('removeOperator')
- expect(engine).to.have.property('addFact')
- expect(engine).to.have.property('removeFact')
- expect(engine).to.have.property('run')
- expect(engine).to.have.property('stop')
- })
-
- describe('constructor', () => {
- it('initializes with the default state', () => {
- expect(engine.status).to.equal('READY')
- expect(engine.rules.length).to.equal(0)
- defaultOperators.forEach(op => {
- expect(engine.operators.get(op.name)).to.be.an.instanceof(Operator)
- })
- })
-
- it('can be initialized with rules', () => {
- const rules = [
- factories.rule(),
- factories.rule(),
- factories.rule()
- ]
- engine = engineFactory(rules)
- expect(engine.rules.length).to.equal(rules.length)
- })
- })
-
- describe('stop()', () => {
- it('changes the status to "FINISHED"', () => {
- expect(engine.stop().status).to.equal('FINISHED')
- })
- })
-
- describe('addRule()', () => {
- describe('rule instance', () => {
- it('adds the rule', () => {
- const rule = new Rule(factories.rule())
- expect(engine.rules.length).to.equal(0)
- engine.addRule(rule)
- expect(engine.rules.length).to.equal(1)
- expect(engine.rules).to.include(rule)
- })
- })
-
- describe('required fields', () => {
- it('.conditions', () => {
- const rule = factories.rule()
- delete rule.conditions
- expect(() => {
- engine.addRule(rule)
- }).to.throw(/Engine: addRule\(\) argument requires "conditions" property/)
- })
-
- it('.event', () => {
- const rule = factories.rule()
- delete rule.event
- expect(() => {
- engine.addRule(rule)
- }).to.throw(/Engine: addRule\(\) argument requires "event" property/)
- })
- })
- })
-
- describe('updateRule()', () => {
- it('updates rule', () => {
- let rule1 = new Rule(factories.rule({ name: 'rule1' }))
- let rule2 = new Rule(factories.rule({ name: 'rule2' }))
- engine.addRule(rule1)
- engine.addRule(rule2)
- expect(engine.rules[0].conditions.all.length).to.equal(2)
- expect(engine.rules[1].conditions.all.length).to.equal(2)
-
- rule1.conditions = { all: [] }
- engine.updateRule(rule1)
-
- rule1 = engine.rules.find(rule => rule.name === 'rule1')
- rule2 = engine.rules.find(rule => rule.name === 'rule2')
- expect(rule1.conditions.all.length).to.equal(0)
- expect(rule2.conditions.all.length).to.equal(2)
- })
-
- it('should throw error if rule not found', () => {
- const rule1 = new Rule(factories.rule({ name: 'rule1' }))
- engine.addRule(rule1)
- const rule2 = new Rule(factories.rule({ name: 'rule2' }))
- expect(() => {
- engine.updateRule(rule2)
- }).to.throw(/Engine: updateRule\(\) rule not found/)
- })
- })
-
- describe('removeRule()', () => {
- function setup () {
- const rule1 = new Rule(factories.rule({ name: 'rule1' }))
- const rule2 = new Rule(factories.rule({ name: 'rule2' }))
- engine.addRule(rule1)
- engine.addRule(rule2)
- engine.prioritizeRules()
-
- return [rule1, rule2]
- }
- context('remove by rule.name', () => {
- it('removes a single rule', () => {
- const [rule1] = setup()
- expect(engine.rules.length).to.equal(2)
-
- const isRemoved = engine.removeRule(rule1.name)
-
- expect(isRemoved).to.be.true()
- expect(engine.rules.length).to.equal(1)
- expect(engine.prioritizedRules).to.equal(null)
- })
-
- it('removes multiple rules with the same name', () => {
- const [rule1] = setup()
- const rule3 = new Rule(factories.rule({ name: rule1.name }))
- engine.addRule(rule3)
- expect(engine.rules.length).to.equal(3)
-
- const isRemoved = engine.removeRule(rule1.name)
-
- expect(isRemoved).to.be.true()
- expect(engine.rules.length).to.equal(1)
- expect(engine.prioritizedRules).to.equal(null)
- })
-
- it('returns false when rule cannot be found', () => {
- setup()
- expect(engine.rules.length).to.equal(2)
-
- const isRemoved = engine.removeRule('not-found-name')
-
- expect(isRemoved).to.be.false()
- expect(engine.rules.length).to.equal(2)
- expect(engine.prioritizedRules).to.not.equal(null)
- })
- })
- context('remove by rule object', () => {
- it('removes a single rule', () => {
- const [rule1] = setup()
- expect(engine.rules.length).to.equal(2)
-
- const isRemoved = engine.removeRule(rule1)
-
- expect(isRemoved).to.be.true()
- expect(engine.rules.length).to.equal(1)
- expect(engine.prioritizedRules).to.equal(null)
- })
-
- it('removes a single rule, even if two have the same name', () => {
- const [rule1] = setup()
- const rule3 = new Rule(factories.rule({ name: rule1.name }))
- engine.addRule(rule3)
- expect(engine.rules.length).to.equal(3)
-
- const isRemoved = engine.removeRule(rule1)
-
- expect(isRemoved).to.be.true()
- expect(engine.rules.length).to.equal(2)
- expect(engine.prioritizedRules).to.equal(null)
- })
-
- it('returns false when rule cannot be found', () => {
- setup()
- expect(engine.rules.length).to.equal(2)
-
- const rule3 = new Rule(factories.rule({ name: 'rule3' }))
- const isRemoved = engine.removeRule(rule3)
-
- expect(isRemoved).to.be.false()
- expect(engine.rules.length).to.equal(2)
- expect(engine.prioritizedRules).to.not.equal(null)
- })
- })
- })
-
- describe('addOperator()', () => {
- it('adds the operator', () => {
- engine.addOperator('startsWithLetter', (factValue, jsonValue) => {
- return factValue[0] === jsonValue
- })
- expect(engine.operators.get('startsWithLetter')).to.exist()
- expect(engine.operators.get('startsWithLetter')).to.be.an.instanceof(Operator)
- })
-
- it('accepts an operator instance', () => {
- const op = new Operator('my-operator', _ => true)
- engine.addOperator(op)
- expect(engine.operators.get('my-operator')).to.equal(op)
- })
- })
-
- describe('removeOperator()', () => {
- it('removes the operator', () => {
- engine.addOperator('startsWithLetter', (factValue, jsonValue) => {
- return factValue[0] === jsonValue
- })
- expect(engine.operators.get('startsWithLetter')).to.be.an.instanceof(Operator)
- engine.removeOperator('startsWithLetter')
- expect(engine.operators.get('startsWithLetter')).to.be.null()
- })
-
- it('can only remove added operators', () => {
- const isRemoved = engine.removeOperator('nonExisting')
- expect(isRemoved).to.equal(false)
- })
- })
-
- describe('addFact()', () => {
- const FACT_NAME = 'FACT_NAME'
- const FACT_VALUE = 'FACT_VALUE'
-
- function assertFact (engine) {
- expect(engine.facts.size).to.equal(1)
- expect(engine.facts.has(FACT_NAME)).to.be.true()
- }
-
- it('allows a constant fact', () => {
- engine.addFact(FACT_NAME, FACT_VALUE)
- assertFact(engine)
- expect(engine.facts.get(FACT_NAME).value).to.equal(FACT_VALUE)
- })
-
- it('allows options to be passed', () => {
- const options = { cache: false }
- engine.addFact(FACT_NAME, FACT_VALUE, options)
- assertFact(engine)
- expect(engine.facts.get(FACT_NAME).value).to.equal(FACT_VALUE)
- expect(engine.facts.get(FACT_NAME).options).to.eql(options)
- })
-
- it('allows a lamba fact with no options', () => {
- engine.addFact(FACT_NAME, async (params, engine) => {
- return FACT_VALUE
- })
- assertFact(engine)
- expect(engine.facts.get(FACT_NAME).value).to.be.undefined()
- })
-
- it('allows a lamba fact with options', () => {
- const options = { cache: false }
- engine.addFact(FACT_NAME, async (params, engine) => {
- return FACT_VALUE
- }, options)
- assertFact(engine)
- expect(engine.facts.get(FACT_NAME).options).to.eql(options)
- expect(engine.facts.get(FACT_NAME).value).to.be.undefined()
- })
-
- it('allows a fact instance', () => {
- const options = { cache: false }
- const fact = new Fact(FACT_NAME, 50, options)
- engine.addFact(fact)
- assertFact(engine)
- expect(engine.facts.get(FACT_NAME)).to.exist()
- expect(engine.facts.get(FACT_NAME).options).to.eql(options)
- })
- })
-
- describe('removeFact()', () => {
- it('removes a Fact', () => {
- expect(engine.facts.size).to.equal(0)
- const fact = new Fact('newFact', 50, { cache: false })
- engine.addFact(fact)
- expect(engine.facts.size).to.equal(1)
- engine.removeFact('newFact')
- expect(engine.facts.size).to.equal(0)
- })
-
- it('can only remove added facts', () => {
- expect(engine.facts.size).to.equal(0)
- const isRemoved = engine.removeFact('newFact')
- expect(isRemoved).to.equal(false)
- })
- })
-
- describe('run()', () => {
- beforeEach(() => {
- const conditions = {
- all: [{
- fact: 'age',
- operator: 'greaterThanInclusive',
- value: 18
- }]
- }
- const event = { type: 'generic' }
- const rule = factories.rule({ conditions, event })
- engine.addRule(rule)
- engine.addFact('age', 20)
- })
-
- it('changes the status to "RUNNING"', () => {
- const eventSpy = sandbox.spy()
- engine.on('success', (event, almanac) => {
- eventSpy()
- expect(engine.status).to.equal('RUNNING')
- })
- return engine.run()
- })
-
- it('changes status to FINISHED once complete', async () => {
- expect(engine.status).to.equal('READY')
- await engine.run()
- expect(engine.status).to.equal('FINISHED')
- })
- })
-})
diff --git a/test/engine.test.mjs b/test/engine.test.mjs
new file mode 100644
index 00000000..6dca0219
--- /dev/null
+++ b/test/engine.test.mjs
@@ -0,0 +1,327 @@
+import engineFactory, { Fact, Rule, Operator } from "../src/index.mjs";
+import defaultOperators from "../src/engine-default-operators.mjs";
+import { describe, it, beforeEach, expect, vi } from "vitest";
+import ruleFactory from "./support/rule-factory.mjs";
+
+describe("Engine", () => {
+ let engine;
+
+ beforeEach(() => {
+ engine = engineFactory();
+ });
+
+ it("has methods for managing facts and rules, and running itself", () => {
+ expect(engine).toHaveProperty("addRule");
+ expect(engine).toHaveProperty("removeRule");
+ expect(engine).toHaveProperty("addOperator");
+ expect(engine).toHaveProperty("removeOperator");
+ expect(engine).toHaveProperty("addFact");
+ expect(engine).toHaveProperty("removeFact");
+ expect(engine).toHaveProperty("run");
+ expect(engine).toHaveProperty("stop");
+ });
+
+ describe("constructor", () => {
+ it("initializes with the default state", () => {
+ expect(engine.status).toBe("READY");
+ expect(engine.rules.length).toBe(0);
+ defaultOperators.forEach((op) => {
+ expect(engine.operators.get(op.name)).toBeInstanceOf(Operator);
+ });
+ });
+
+ it("can be initialized with rules", () => {
+ const rules = [ruleFactory(), ruleFactory(), ruleFactory()];
+ engine = engineFactory(rules);
+ expect(engine.rules.length).toBe(rules.length);
+ });
+ });
+
+ describe("stop()", () => {
+ it('changes the status to "FINISHED"', () => {
+ expect(engine.stop().status).toBe("FINISHED");
+ });
+ });
+
+ describe("addRule()", () => {
+ describe("rule instance", () => {
+ it("adds the rule", () => {
+ const rule = new Rule(ruleFactory());
+ expect(engine.rules.length).toBe(0);
+ engine.addRule(rule);
+ expect(engine.rules.length).toBe(1);
+ expect(engine.rules).toContain(rule);
+ });
+ });
+
+ describe("required fields", () => {
+ it(".conditions", () => {
+ const rule = ruleFactory();
+ delete rule.conditions;
+ expect(() => {
+ engine.addRule(rule);
+ }).toThrow(
+ /Engine: addRule\(\) argument requires "conditions" property/,
+ );
+ });
+
+ it(".event", () => {
+ const rule = ruleFactory();
+ delete rule.event;
+ expect(() => {
+ engine.addRule(rule);
+ }).toThrow(/Engine: addRule\(\) argument requires "event" property/);
+ });
+ });
+ });
+
+ describe("updateRule()", () => {
+ it("updates rule", () => {
+ let rule1 = new Rule(ruleFactory({ name: "rule1" }));
+ let rule2 = new Rule(ruleFactory({ name: "rule2" }));
+ engine.addRule(rule1);
+ engine.addRule(rule2);
+ expect(engine.rules[0].conditions.all.length).toBe(2);
+ expect(engine.rules[1].conditions.all.length).toBe(2);
+
+ rule1.conditions = { all: [] };
+ engine.updateRule(rule1);
+
+ rule1 = engine.rules.find((rule) => rule.name === "rule1");
+ rule2 = engine.rules.find((rule) => rule.name === "rule2");
+ expect(rule1.conditions.all.length).toBe(0);
+ expect(rule2.conditions.all.length).toBe(2);
+ });
+
+ it("should throw error if rule not found", () => {
+ const rule1 = new Rule(ruleFactory({ name: "rule1" }));
+ engine.addRule(rule1);
+ const rule2 = new Rule(ruleFactory({ name: "rule2" }));
+ expect(() => {
+ engine.updateRule(rule2);
+ }).toThrow(/Engine: updateRule\(\) rule not found/);
+ });
+ });
+
+ describe("removeRule()", () => {
+ function setup() {
+ const rule1 = new Rule(ruleFactory({ name: "rule1" }));
+ const rule2 = new Rule(ruleFactory({ name: "rule2" }));
+ engine.addRule(rule1);
+ engine.addRule(rule2);
+ engine.prioritizeRules();
+
+ return [rule1, rule2];
+ }
+ describe("remove by rule.name", () => {
+ it("removes a single rule", () => {
+ const [rule1] = setup();
+ expect(engine.rules.length).toBe(2);
+
+ const isRemoved = engine.removeRule(rule1.name);
+
+ expect(isRemoved).toBe(true);
+ expect(engine.rules.length).toBe(1);
+ expect(engine.prioritizedRules).toBeNull();
+ });
+
+ it("removes multiple rules with the same name", () => {
+ const [rule1] = setup();
+ const rule3 = new Rule(ruleFactory({ name: rule1.name }));
+ engine.addRule(rule3);
+ expect(engine.rules.length).toBe(3);
+
+ const isRemoved = engine.removeRule(rule1.name);
+
+ expect(isRemoved).toBe(true);
+ expect(engine.rules.length).toBe(1);
+ expect(engine.prioritizedRules).toBeNull();
+ });
+
+ it("returns false when rule cannot be found", () => {
+ setup();
+ expect(engine.rules.length).toBe(2);
+
+ const isRemoved = engine.removeRule("not-found-name");
+
+ expect(isRemoved).toBe(false);
+ expect(engine.rules.length).toBe(2);
+ expect(engine.prioritizedRules).not.toBeNull();
+ });
+ });
+ describe("remove by rule object", () => {
+ it("removes a single rule", () => {
+ const [rule1] = setup();
+ expect(engine.rules.length).toBe(2);
+
+ const isRemoved = engine.removeRule(rule1);
+
+ expect(isRemoved).toBe(true);
+ expect(engine.rules.length).toBe(1);
+ expect(engine.prioritizedRules).toBeNull();
+ });
+
+ it("removes a single rule, even if two have the same name", () => {
+ const [rule1] = setup();
+ const rule3 = new Rule(ruleFactory({ name: rule1.name }));
+ engine.addRule(rule3);
+ expect(engine.rules.length).toBe(3);
+
+ const isRemoved = engine.removeRule(rule1);
+
+ expect(isRemoved).toBe(true);
+ expect(engine.rules.length).toBe(2);
+ expect(engine.prioritizedRules).toBeNull();
+ });
+
+ it("returns false when rule cannot be found", () => {
+ setup();
+ expect(engine.rules.length).toBe(2);
+
+ const rule3 = new Rule(ruleFactory({ name: "rule3" }));
+ const isRemoved = engine.removeRule(rule3);
+
+ expect(isRemoved).toBe(false);
+ expect(engine.rules.length).toBe(2);
+ expect(engine.prioritizedRules).not.toBeNull();
+ });
+ });
+ });
+
+ describe("addOperator()", () => {
+ it("adds the operator", () => {
+ engine.addOperator("startsWithLetter", (factValue, jsonValue) => {
+ return factValue[0] === jsonValue;
+ });
+ expect(engine.operators.get("startsWithLetter")).toBeDefined();
+ expect(engine.operators.get("startsWithLetter")).toBeInstanceOf(Operator);
+ });
+
+ it("accepts an operator instance", () => {
+ const op = new Operator("my-operator", () => true);
+ engine.addOperator(op);
+ expect(engine.operators.get("my-operator")).toEqual(op);
+ });
+ });
+
+ describe("removeOperator()", () => {
+ it("removes the operator", () => {
+ engine.addOperator("startsWithLetter", (factValue, jsonValue) => {
+ return factValue[0] === jsonValue;
+ });
+ expect(engine.operators.get("startsWithLetter")).toBeInstanceOf(Operator);
+ engine.removeOperator("startsWithLetter");
+ expect(engine.operators.get("startsWithLetter")).toBeNull();
+ });
+
+ it("can only remove added operators", () => {
+ const isRemoved = engine.removeOperator("nonExisting");
+ expect(isRemoved).toBe(false);
+ });
+ });
+
+ describe("addFact()", () => {
+ const FACT_NAME = "FACT_NAME";
+ const FACT_VALUE = "FACT_VALUE";
+
+ function assertFact(engine) {
+ expect(engine.facts.size).toBe(1);
+ expect(engine.facts.has(FACT_NAME)).toBe(true);
+ }
+
+ it("allows a constant fact", () => {
+ engine.addFact(FACT_NAME, FACT_VALUE);
+ assertFact(engine);
+ expect(engine.facts.get(FACT_NAME).value).toBe(FACT_VALUE);
+ });
+
+ it("allows options to be passed", () => {
+ const options = { cache: false };
+ engine.addFact(FACT_NAME, FACT_VALUE, options);
+ assertFact(engine);
+ expect(engine.facts.get(FACT_NAME).value).toBe(FACT_VALUE);
+ expect(engine.facts.get(FACT_NAME).options).toEqual(options);
+ });
+
+ it("allows a lamba fact with no options", () => {
+ engine.addFact(FACT_NAME, async () => {
+ return FACT_VALUE;
+ });
+ assertFact(engine);
+ expect(engine.facts.get(FACT_NAME).value).toBeUndefined();
+ });
+
+ it("allows a lamba fact with options", () => {
+ const options = { cache: false };
+ engine.addFact(
+ FACT_NAME,
+ async () => {
+ return FACT_VALUE;
+ },
+ options,
+ );
+ assertFact(engine);
+ expect(engine.facts.get(FACT_NAME).options).toEqual(options);
+ expect(engine.facts.get(FACT_NAME).value).toBeUndefined();
+ });
+
+ it("allows a fact instance", () => {
+ const options = { cache: false };
+ const fact = new Fact(FACT_NAME, 50, options);
+ engine.addFact(fact);
+ assertFact(engine);
+ expect(engine.facts.get(FACT_NAME)).toBeDefined();
+ expect(engine.facts.get(FACT_NAME).options).toEqual(options);
+ });
+ });
+
+ describe("removeFact()", () => {
+ it("removes a Fact", () => {
+ expect(engine.facts.size).toBe(0);
+ const fact = new Fact("newFact", 50, { cache: false });
+ engine.addFact(fact);
+ expect(engine.facts.size).toBe(1);
+ engine.removeFact("newFact");
+ expect(engine.facts.size).toBe(0);
+ });
+
+ it("can only remove added facts", () => {
+ expect(engine.facts.size).toBe(0);
+ const isRemoved = engine.removeFact("newFact");
+ expect(isRemoved).toBe(false);
+ });
+ });
+
+ describe("run()", () => {
+ beforeEach(() => {
+ const conditions = {
+ all: [
+ {
+ fact: "age",
+ operator: "greaterThanInclusive",
+ value: 18,
+ },
+ ],
+ };
+ const event = { type: "generic" };
+ const rule = ruleFactory({ conditions, event });
+ engine.addRule(rule);
+ engine.addFact("age", 20);
+ });
+
+ it('changes the status to "RUNNING"', () => {
+ const eventSpy = vi.fn();
+ engine.on("success", () => {
+ eventSpy();
+ expect(engine.status).toBe("RUNNING");
+ });
+ return engine.run();
+ });
+
+ it("changes status to FINISHED once complete", async () => {
+ expect(engine.status).toBe("READY");
+ await engine.run();
+ expect(engine.status).toBe("FINISHED");
+ });
+ });
+});
diff --git a/test/fact.test.js b/test/fact.test.js
deleted file mode 100644
index 4504fa8c..00000000
--- a/test/fact.test.js
+++ /dev/null
@@ -1,48 +0,0 @@
-'use strict'
-
-import { Fact } from '../src/index'
-
-describe('Fact', () => {
- function subject (id, definition, options) {
- return new Fact(id, definition, options)
- }
- describe('Fact::constructor', () => {
- it('works for constant facts', () => {
- const fact = subject('factId', 10)
- expect(fact.id).to.equal('factId')
- expect(fact.value).to.equal(10)
- })
-
- it('works for dynamic facts', () => {
- const fact = subject('factId', () => 10)
- expect(fact.id).to.equal('factId')
- expect(fact.calculate()).to.equal(10)
- })
-
- it('allows options to be passed', () => {
- const opts = { test: true, cache: false }
- const fact = subject('factId', 10, opts)
- expect(fact.options).to.eql(opts)
- })
-
- describe('validations', () => {
- it('throws if no id provided', () => {
- expect(subject).to.throw(/factId required/)
- })
- })
- })
-
- describe('Fact::types', () => {
- it('initializes facts with method values as dynamic', () => {
- const fact = subject('factId', () => {})
- expect(fact.type).to.equal(Fact.DYNAMIC)
- expect(fact.isDynamic()).to.be.true()
- })
-
- it('initializes facts with non-methods as constant', () => {
- const fact = subject('factId', 2)
- expect(fact.type).to.equal(Fact.CONSTANT)
- expect(fact.isConstant()).to.be.true()
- })
- })
-})
diff --git a/test/fact.test.mjs b/test/fact.test.mjs
new file mode 100644
index 00000000..9602c13a
--- /dev/null
+++ b/test/fact.test.mjs
@@ -0,0 +1,47 @@
+import { Fact } from "../src/index.mjs";
+import { describe, it, expect } from "vitest";
+
+describe("Fact", () => {
+ function subject(id, definition, options) {
+ return new Fact(id, definition, options);
+ }
+ describe("Fact::constructor", () => {
+ it("works for constant facts", () => {
+ const fact = subject("factId", 10);
+ expect(fact.id).toBe("factId");
+ expect(fact.value).toBe(10);
+ });
+
+ it("works for dynamic facts", () => {
+ const fact = subject("factId", () => 10);
+ expect(fact.id).toBe("factId");
+ expect(fact.calculate()).toBe(10);
+ });
+
+ it("allows options to be passed", () => {
+ const opts = { test: true, cache: false };
+ const fact = subject("factId", 10, opts);
+ expect(fact.options).toEqual(opts);
+ });
+
+ describe("validations", () => {
+ it("throws if no id provided", () => {
+ expect(subject).toThrow(/factId required/);
+ });
+ });
+ });
+
+ describe("Fact::types", () => {
+ it("initializes facts with method values as dynamic", () => {
+ const fact = subject("factId", () => {});
+ expect(fact.type).toBe(Fact.DYNAMIC);
+ expect(fact.isDynamic()).toBe(true);
+ });
+
+ it("initializes facts with non-methods as constant", () => {
+ const fact = subject("factId", 2);
+ expect(fact.type).toBe(Fact.CONSTANT);
+ expect(fact.isConstant()).toBe(true);
+ });
+ });
+});
diff --git a/test/index.test.js b/test/index.test.js
deleted file mode 100644
index 350c3ef7..00000000
--- a/test/index.test.js
+++ /dev/null
@@ -1,14 +0,0 @@
-'use strict'
-
-import subject from '../src/index'
-
-describe('json-business-subject', () => {
- it('treats each rule engine independently', () => {
- const engine1 = subject()
- const engine2 = subject()
- engine1.addRule(factories.rule())
- engine2.addRule(factories.rule())
- expect(engine1.rules.length).to.equal(1)
- expect(engine2.rules.length).to.equal(1)
- })
-})
diff --git a/test/index.test.mjs b/test/index.test.mjs
new file mode 100644
index 00000000..e45c60b3
--- /dev/null
+++ b/test/index.test.mjs
@@ -0,0 +1,14 @@
+import subject from "../src/index.mjs";
+import { describe, it, expect } from "vitest";
+import ruleFactory from "./support/rule-factory.mjs";
+
+describe("json-business-subject", () => {
+ it("treats each rule engine independently", () => {
+ const engine1 = subject();
+ const engine2 = subject();
+ engine1.addRule(ruleFactory());
+ engine2.addRule(ruleFactory());
+ expect(engine1.rules.length).toBe(1);
+ expect(engine2.rules.length).toBe(1);
+ });
+});
diff --git a/test/operator-decorator.test.js b/test/operator-decorator.test.js
deleted file mode 100644
index 3c10ff4d..00000000
--- a/test/operator-decorator.test.js
+++ /dev/null
@@ -1,40 +0,0 @@
-'use strict'
-
-import { OperatorDecorator, Operator } from '../src/index'
-
-const startsWithLetter = new Operator('startsWithLetter', (factValue, jsonValue) => {
- return factValue[0] === jsonValue
-})
-
-describe('OperatorDecorator', () => {
- describe('constructor()', () => {
- function subject (...args) {
- return new OperatorDecorator(...args)
- }
-
- it('adds the decorator', () => {
- const decorator = subject('test', () => false)
- expect(decorator.name).to.equal('test')
- expect(decorator.cb).to.an.instanceof(Function)
- })
-
- it('decorator name', () => {
- expect(() => {
- subject()
- }).to.throw(/Missing decorator name/)
- })
-
- it('decorator definition', () => {
- expect(() => {
- subject('test')
- }).to.throw(/Missing decorator callback/)
- })
- })
-
- describe('decorating', () => {
- const subject = new OperatorDecorator('test', () => false).decorate(startsWithLetter)
- it('creates a new operator with the prefixed name', () => {
- expect(subject.name).to.equal('test:startsWithLetter')
- })
- })
-})
diff --git a/test/operator-decorator.test.mjs b/test/operator-decorator.test.mjs
new file mode 100644
index 00000000..28df63f3
--- /dev/null
+++ b/test/operator-decorator.test.mjs
@@ -0,0 +1,44 @@
+import { OperatorDecorator, Operator } from "../src/index.mjs";
+import { describe, it, expect } from "vitest";
+
+const startsWithLetter = new Operator(
+ "startsWithLetter",
+ (factValue, jsonValue) => {
+ return factValue[0] === jsonValue;
+ },
+);
+
+describe("OperatorDecorator", () => {
+ describe("constructor()", () => {
+ function subject(...args) {
+ return new OperatorDecorator(...args);
+ }
+
+ it("adds the decorator", () => {
+ const decorator = subject("test", () => false);
+ expect(decorator.name).toBe("test");
+ expect(decorator.cb).toBeInstanceOf(Function);
+ });
+
+ it("decorator name", () => {
+ expect(() => {
+ subject();
+ }).toThrow(/Missing decorator name/);
+ });
+
+ it("decorator definition", () => {
+ expect(() => {
+ subject("test");
+ }).toThrow(/Missing decorator callback/);
+ });
+ });
+
+ describe("decorating", () => {
+ const subject = new OperatorDecorator("test", () => false).decorate(
+ startsWithLetter,
+ );
+ it("creates a new operator with the prefixed name", () => {
+ expect(subject.name).toBe("test:startsWithLetter");
+ });
+ });
+});
diff --git a/test/operator.test.js b/test/operator.test.js
deleted file mode 100644
index 24cc89aa..00000000
--- a/test/operator.test.js
+++ /dev/null
@@ -1,31 +0,0 @@
-'use strict'
-
-import { Operator } from '../src/index'
-
-describe('Operator', () => {
- describe('constructor()', () => {
- function subject (...args) {
- return new Operator(...args)
- }
-
- it('adds the operator', () => {
- const operator = subject('startsWithLetter', (factValue, jsonValue) => {
- return factValue[0] === jsonValue
- })
- expect(operator.name).to.equal('startsWithLetter')
- expect(operator.cb).to.an.instanceof(Function)
- })
-
- it('operator name', () => {
- expect(() => {
- subject()
- }).to.throw(/Missing operator name/)
- })
-
- it('operator definition', () => {
- expect(() => {
- subject('startsWithLetter')
- }).to.throw(/Missing operator callback/)
- })
- })
-})
diff --git a/test/operator.test.mjs b/test/operator.test.mjs
new file mode 100644
index 00000000..78687324
--- /dev/null
+++ b/test/operator.test.mjs
@@ -0,0 +1,30 @@
+import { Operator } from "../src/index.mjs";
+import { describe, it, expect } from "vitest";
+
+describe("Operator", () => {
+ describe("constructor()", () => {
+ function subject(...args) {
+ return new Operator(...args);
+ }
+
+ it("adds the operator", () => {
+ const operator = subject("startsWithLetter", (factValue, jsonValue) => {
+ return factValue[0] === jsonValue;
+ });
+ expect(operator.name).toBe("startsWithLetter");
+ expect(operator.cb).toBeInstanceOf(Function);
+ });
+
+ it("operator name", () => {
+ expect(() => {
+ subject();
+ }).toThrow(/Missing operator name/);
+ });
+
+ it("operator definition", () => {
+ expect(() => {
+ subject("startsWithLetter");
+ }).toThrow(/Missing operator callback/);
+ });
+ });
+});
diff --git a/test/performance.test.js b/test/performance.test.js
deleted file mode 100644
index 7a0ce6d4..00000000
--- a/test/performance.test.js
+++ /dev/null
@@ -1,65 +0,0 @@
-'use strict'
-
-import engineFactory from '../src/index'
-import perfy from 'perfy'
-import deepClone from 'clone'
-
-describe('Performance', () => {
- const baseConditions = {
- any: [{
- fact: 'age',
- operator: 'lessThan',
- value: 50
- },
- {
- fact: 'segment',
- operator: 'equal',
- value: 'european'
- }]
- }
- const event = {
- type: 'ageTrigger',
- params: {
- demographic: 'under50'
- }
- }
- /*
- * Generates an array of integers of length 'num'
- */
- function range (num) {
- return Array.from(Array(num).keys())
- }
-
- function setup (conditions) {
- const engine = engineFactory()
- const config = deepClone({ conditions, event })
- range(1000).forEach(() => {
- const rule = factories.rule(config)
- engine.addRule(rule)
- })
- engine.addFact('segment', 'european', { cache: true })
- engine.addFact('age', 15, { cache: true })
- return engine
- }
-
- it('performs "any" quickly', async () => {
- const engine = setup(baseConditions)
- perfy.start('any')
- await engine.run()
- const result = perfy.end('any')
- expect(result.time).to.be.greaterThan(0.001)
- expect(result.time).to.be.lessThan(0.5)
- })
-
- it('performs "all" quickly', async () => {
- const conditions = deepClone(baseConditions)
- conditions.all = conditions.any
- delete conditions.any
- const engine = setup(conditions)
- perfy.start('all')
- await engine.run()
- const result = perfy.end('all')
- expect(result.time).to.be.greaterThan(0.001) // assert lower value
- expect(result.time).to.be.lessThan(0.5)
- })
-})
diff --git a/test/performance.test.mjs b/test/performance.test.mjs
new file mode 100644
index 00000000..83a0d91f
--- /dev/null
+++ b/test/performance.test.mjs
@@ -0,0 +1,67 @@
+import engineFactory from "../src/index.mjs";
+import perfy from "perfy";
+import deepClone from "clone";
+import { describe, it, expect } from "vitest";
+import ruleFactory from "./support/rule-factory.mjs";
+
+describe("Performance", () => {
+ const baseConditions = {
+ any: [
+ {
+ fact: "age",
+ operator: "lessThan",
+ value: 50,
+ },
+ {
+ fact: "segment",
+ operator: "equal",
+ value: "european",
+ },
+ ],
+ };
+ const event = {
+ type: "ageTrigger",
+ params: {
+ demographic: "under50",
+ },
+ };
+ /*
+ * Generates an array of integers of length 'num'
+ */
+ function range(num) {
+ return Array.from(Array(num).keys());
+ }
+
+ function setup(conditions) {
+ const engine = engineFactory();
+ const config = deepClone({ conditions, event });
+ range(1000).forEach(() => {
+ const rule = ruleFactory(config);
+ engine.addRule(rule);
+ });
+ engine.addFact("segment", "european", { cache: true });
+ engine.addFact("age", 15, { cache: true });
+ return engine;
+ }
+
+ it('performs "any" quickly', async () => {
+ const engine = setup(baseConditions);
+ perfy.start("any");
+ await engine.run();
+ const result = perfy.end("any");
+ expect(result.time).toBeGreaterThan(0.001);
+ expect(result.time).toBeLessThan(0.5);
+ });
+
+ it('performs "all" quickly', async () => {
+ const conditions = deepClone(baseConditions);
+ conditions.all = conditions.any;
+ delete conditions.any;
+ const engine = setup(conditions);
+ perfy.start("all");
+ await engine.run();
+ const result = perfy.end("all");
+ expect(result.time).toBeGreaterThan(0.001); // assert lower value
+ expect(result.time).toBeLessThan(0.5);
+ });
+});
diff --git a/test/rule.test.js b/test/rule.test.js
deleted file mode 100644
index fc3357b8..00000000
--- a/test/rule.test.js
+++ /dev/null
@@ -1,339 +0,0 @@
-'use strict'
-
-import Engine from '../src/index'
-import Rule from '../src/rule'
-import sinon from 'sinon'
-
-describe('Rule', () => {
- const rule = new Rule()
- const conditionBase = factories.condition({
- fact: 'age',
- value: 50
- })
-
- describe('constructor()', () => {
- it('can be initialized with priority, conditions, event, and name', () => {
- const condition = {
- all: [Object.assign({}, conditionBase)]
- }
- condition.operator = 'all'
- condition.priority = 25
- const opts = {
- priority: 50,
- conditions: condition,
- event: {
- type: 'awesome'
- },
- name: 'testName'
- }
- const rule = new Rule(opts)
- expect(rule.priority).to.eql(opts.priority)
- expect(rule.conditions).to.eql(opts.conditions)
- expect(rule.ruleEvent).to.eql(opts.event)
- expect(rule.name).to.eql(opts.name)
- })
-
- it('it can be initialized with a json string', () => {
- const condition = {
- all: [Object.assign({}, conditionBase)]
- }
- condition.operator = 'all'
- condition.priority = 25
- const opts = {
- priority: 50,
- conditions: condition,
- event: {
- type: 'awesome'
- },
- name: 'testName'
- }
- const json = JSON.stringify(opts)
- const rule = new Rule(json)
- expect(rule.priority).to.eql(opts.priority)
- expect(rule.conditions).to.eql(opts.conditions)
- expect(rule.ruleEvent).to.eql(opts.event)
- expect(rule.name).to.eql(opts.name)
- })
- })
-
- describe('event emissions', () => {
- it('can emit', () => {
- const rule = new Rule()
- const successSpy = sinon.spy()
- rule.on('test', successSpy)
- rule.emit('test')
- expect(successSpy.callCount).to.equal(1)
- })
-
- it('can be initialized with an onSuccess option', (done) => {
- const event = { type: 'test' }
- const onSuccess = function (e) {
- expect(e).to.equal(event)
- done()
- }
- const rule = new Rule({ onSuccess })
- rule.emit('success', event)
- })
-
- it('can be initialized with an onFailure option', (done) => {
- const event = { type: 'test' }
- const onFailure = function (e) {
- expect(e).to.equal(event)
- done()
- }
- const rule = new Rule({ onFailure })
- rule.emit('failure', event)
- })
- })
-
- describe('setEvent()', () => {
- it('throws if no argument provided', () => {
- expect(() => rule.setEvent()).to.throw(/Rule: setEvent\(\) requires event object/)
- })
-
- it('throws if argument is missing "type" property', () => {
- expect(() => rule.setEvent({})).to.throw(/Rule: setEvent\(\) requires event object with "type" property/)
- })
- })
-
- describe('setEvent()', () => {
- it('throws if no argument provided', () => {
- expect(() => rule.setEvent()).to.throw(/Rule: setEvent\(\) requires event object/)
- })
-
- it('throws if argument is missing "type" property', () => {
- expect(() => rule.setEvent({})).to.throw(/Rule: setEvent\(\) requires event object with "type" property/)
- })
- })
-
- describe('setConditions()', () => {
- describe('validations', () => {
- it('throws an exception for invalid root conditions', () => {
- expect(rule.setConditions.bind(rule, { foo: true })).to.throw(/"conditions" root must contain a single instance of "all", "any", "not", or "condition"/)
- })
- })
- })
-
- describe('setPriority', () => {
- it('defaults to a priority of 1', () => {
- expect(rule.priority).to.equal(1)
- })
-
- it('allows a priority to be set', () => {
- rule.setPriority(10)
- expect(rule.priority).to.equal(10)
- })
-
- it('errors if priority is less than 0', () => {
- expect(rule.setPriority.bind(null, 0)).to.throw(/greater than zero/)
- })
- })
-
- describe('accessors', () => {
- it('retrieves event', () => {
- const event = { type: 'e', params: { a: 'b' } }
- rule.setEvent(event)
- expect(rule.getEvent()).to.deep.equal(event)
- })
-
- it('retrieves priority', () => {
- const priority = 100
- rule.setPriority(priority)
- expect(rule.getPriority()).to.equal(priority)
- })
-
- it('retrieves conditions', () => {
- const condition = { all: [] }
- rule.setConditions(condition)
- expect(rule.getConditions()).to.deep.equal({
- all: [],
- operator: 'all',
- priority: 1
- })
- })
- })
-
- describe('setName', () => {
- it('defaults to undefined', () => {
- expect(rule.name).to.equal(undefined)
- })
-
- it('allows the name to be set', () => {
- rule.setName('Test Name')
- expect(rule.name).to.equal('Test Name')
- })
-
- it('allows input of the number 0', () => {
- rule.setName(0)
- expect(rule.name).to.equal(0)
- })
-
- it('allows input of an object', () => {
- rule.setName({
- id: 123,
- name: 'myRule'
- })
- expect(rule.name).to.eql({
- id: 123,
- name: 'myRule'
- })
- })
-
- it('errors if name is an empty string', () => {
- expect(rule.setName.bind(null, '')).to.throw(/Rule "name" must be defined/)
- })
- })
-
- describe('priotizeConditions()', () => {
- const conditions = [{
- fact: 'age',
- operator: 'greaterThanInclusive',
- value: 18
- }, {
- fact: 'segment',
- operator: 'equal',
- value: 'human'
- }, {
- fact: 'accountType',
- operator: 'equal',
- value: 'admin'
- }, {
- fact: 'state',
- operator: 'equal',
- value: 'admin'
- }]
-
- it('orders based on priority', async () => {
- const engine = new Engine()
- engine.addFact('state', async () => {}, { priority: 500 })
- engine.addFact('segment', async () => {}, { priority: 50 })
- engine.addFact('accountType', async () => {}, { priority: 25 })
- engine.addFact('age', async () => {}, { priority: 100 })
- const rule = new Rule()
- rule.setEngine(engine)
-
- const prioritizedConditions = rule.prioritizeConditions(conditions)
- expect(prioritizedConditions.length).to.equal(4)
- expect(prioritizedConditions[0][0].fact).to.equal('state')
- expect(prioritizedConditions[1][0].fact).to.equal('age')
- expect(prioritizedConditions[2][0].fact).to.equal('segment')
- expect(prioritizedConditions[3][0].fact).to.equal('accountType')
- })
- })
-
- describe('evaluate()', () => {
- function setup () {
- const engine = new Engine()
- const rule = new Rule()
- rule.setConditions({
- all: []
- })
- engine.addRule(rule)
-
- return { engine, rule }
- }
- it('evalutes truthy when there are no conditions', async () => {
- const engineSuccessSpy = sinon.spy()
- const { engine } = setup()
-
- engine.on('success', engineSuccessSpy)
-
- await engine.run()
-
- expect(engineSuccessSpy).to.have.been.calledOnce()
- })
-
- it('waits for all on("success") event promises to be resolved', async () => {
- const engineSuccessSpy = sinon.spy()
- const ruleSuccessSpy = sinon.spy()
- const engineRunSpy = sinon.spy()
- const { engine, rule } = setup()
- rule.on('success', () => {
- return new Promise(function (resolve) {
- setTimeout(function () {
- ruleSuccessSpy()
- resolve()
- }, 5)
- })
- })
- engine.on('success', engineSuccessSpy)
-
- await engine.run().then(() => engineRunSpy())
-
- expect(ruleSuccessSpy).to.have.been.calledOnce()
- expect(engineSuccessSpy).to.have.been.calledOnce()
- expect(ruleSuccessSpy).to.have.been.calledBefore(engineRunSpy)
- expect(ruleSuccessSpy).to.have.been.calledBefore(engineSuccessSpy)
- })
- })
-
- describe('toJSON() and fromJSON()', () => {
- const priority = 50
- const event = {
- type: 'to-json!',
- params: { id: 1 }
- }
- const conditions = {
- priority: 1,
- all: [{
- value: 10,
- operator: 'equals',
- fact: 'user',
- params: {
- foo: true
- },
- path: '$.id'
- }]
- }
- const name = 'testName'
- let rule
- beforeEach(() => {
- rule = new Rule()
- rule.setConditions(conditions)
- rule.setPriority(priority)
- rule.setEvent(event)
- rule.setName(name)
- })
-
- it('serializes itself', () => {
- const json = rule.toJSON(false)
- expect(Object.keys(json).length).to.equal(4)
- expect(json.conditions).to.eql(conditions)
- expect(json.priority).to.eql(priority)
- expect(json.event).to.eql(event)
- expect(json.name).to.eql(name)
- })
-
- it('serializes itself as json', () => {
- const jsonString = rule.toJSON()
- expect(jsonString).to.be.a('string')
- const json = JSON.parse(jsonString)
- expect(Object.keys(json).length).to.equal(4)
- expect(json.conditions).to.eql(conditions)
- expect(json.priority).to.eql(priority)
- expect(json.event).to.eql(event)
- expect(json.name).to.eql(name)
- })
-
- it('rehydrates itself using a JSON string', () => {
- const jsonString = rule.toJSON()
- expect(jsonString).to.be.a('string')
- const hydratedRule = new Rule(jsonString)
- expect(hydratedRule.conditions).to.eql(rule.conditions)
- expect(hydratedRule.priority).to.eql(rule.priority)
- expect(hydratedRule.ruleEvent).to.eql(rule.ruleEvent)
- expect(hydratedRule.name).to.eql(rule.name)
- })
-
- it('rehydrates itself using an object from JSON.parse()', () => {
- const jsonString = rule.toJSON()
- expect(jsonString).to.be.a('string')
- const json = JSON.parse(jsonString)
- const hydratedRule = new Rule(json)
- expect(hydratedRule.conditions).to.eql(rule.conditions)
- expect(hydratedRule.priority).to.eql(rule.priority)
- expect(hydratedRule.ruleEvent).to.eql(rule.ruleEvent)
- expect(hydratedRule.name).to.eql(rule.name)
- })
- })
-})
diff --git a/test/rule.test.mjs b/test/rule.test.mjs
new file mode 100644
index 00000000..05c26aef
--- /dev/null
+++ b/test/rule.test.mjs
@@ -0,0 +1,358 @@
+import Engine from "../src/index.mjs";
+import Rule from "../src/rule.mjs";
+
+import { describe, it, beforeEach, expect, vi } from "vitest";
+import conditionFactory from "./support/condition-factory.mjs";
+
+describe("Rule", () => {
+ const rule = new Rule();
+ const conditionBase = conditionFactory({
+ fact: "age",
+ value: 50,
+ });
+
+ describe("constructor()", () => {
+ it("can be initialized with priority, conditions, event, and name", () => {
+ const condition = {
+ all: [Object.assign({}, conditionBase)],
+ };
+ condition.operator = "all";
+ condition.priority = 25;
+ const opts = {
+ priority: 50,
+ conditions: condition,
+ event: {
+ type: "awesome",
+ },
+ name: "testName",
+ };
+ const rule = new Rule(opts);
+ expect(rule.priority).toEqual(opts.priority);
+ expect(rule.conditions).toEqual(opts.conditions);
+ expect(rule.ruleEvent).toEqual(opts.event);
+ expect(rule.name).toBe(opts.name);
+ });
+
+ it("it can be initialized with a json string", () => {
+ const condition = {
+ all: [Object.assign({}, conditionBase)],
+ };
+ condition.operator = "all";
+ condition.priority = 25;
+ const opts = {
+ priority: 50,
+ conditions: condition,
+ event: {
+ type: "awesome",
+ },
+ name: "testName",
+ };
+ const json = JSON.stringify(opts);
+ const rule = new Rule(json);
+ expect(rule.priority).toEqual(opts.priority);
+ expect(rule.conditions).toEqual(opts.conditions);
+ expect(rule.ruleEvent).toEqual(opts.event);
+ expect(rule.name).toEqual(opts.name);
+ });
+ });
+
+ describe("event emissions", () => {
+ it("can emit", () => {
+ const rule = new Rule();
+ const successSpy = vi.fn();
+ rule.on("test", successSpy);
+ rule.emit("test");
+ expect(successSpy).toHaveBeenCalledOnce();
+ });
+
+ it("can be initialized with an onSuccess option", () => {
+ const event = { type: "test" };
+ const onSuccess = vi.fn();
+ const rule = new Rule({ onSuccess });
+ rule.emit("success", event);
+ expect(onSuccess).toHaveBeenCalledWith(event);
+ });
+
+ it("can be initialized with an onFailure option", () => {
+ const event = { type: "test" };
+ const onFailure = vi.fn();
+ const rule = new Rule({ onFailure });
+ rule.emit("failure", event);
+ expect(onFailure).toHaveBeenCalledWith(event);
+ });
+ });
+
+ describe("setEvent()", () => {
+ it("throws if no argument provided", () => {
+ expect(() => rule.setEvent()).toThrow(
+ /Rule: setEvent\(\) requires event object/,
+ );
+ });
+
+ it('throws if argument is missing "type" property', () => {
+ expect(() => rule.setEvent({})).toThrow(
+ /Rule: setEvent\(\) requires event object with "type" property/,
+ );
+ });
+ });
+
+ describe("setEvent()", () => {
+ it("throws if no argument provided", () => {
+ expect(() => rule.setEvent()).toThrow(
+ /Rule: setEvent\(\) requires event object/,
+ );
+ });
+
+ it('throws if argument is missing "type" property', () => {
+ expect(() => rule.setEvent({})).toThrow(
+ /Rule: setEvent\(\) requires event object with "type" property/,
+ );
+ });
+ });
+
+ describe("setConditions()", () => {
+ describe("validations", () => {
+ it("throws an exception for invalid root conditions", () => {
+ expect(rule.setConditions.bind(rule, { foo: true })).toThrow(
+ /"conditions" root must contain a single instance of "all", "any", "not", or "condition"/,
+ );
+ });
+ });
+ });
+
+ describe("setPriority", () => {
+ it("defaults to a priority of 1", () => {
+ expect(rule.priority).toBe(1);
+ });
+
+ it("allows a priority to be set", () => {
+ rule.setPriority(10);
+ expect(rule.priority).toBe(10);
+ });
+
+ it("errors if priority is less than 0", () => {
+ expect(rule.setPriority.bind(null, 0)).toThrow(/greater than zero/);
+ });
+ });
+
+ describe("accessors", () => {
+ it("retrieves event", () => {
+ const event = { type: "e", params: { a: "b" } };
+ rule.setEvent(event);
+ expect(rule.getEvent()).toEqual(event);
+ });
+
+ it("retrieves priority", () => {
+ const priority = 100;
+ rule.setPriority(priority);
+ expect(rule.getPriority()).toBe(priority);
+ });
+
+ it("retrieves conditions", () => {
+ const condition = { all: [] };
+ rule.setConditions(condition);
+ expect(rule.getConditions()).toEqual({
+ all: [],
+ operator: "all",
+ priority: 1,
+ });
+ });
+ });
+
+ describe("setName", () => {
+ it("defaults to undefined", () => {
+ expect(rule.name).toBeUndefined();
+ });
+
+ it("allows the name to be set", () => {
+ rule.setName("Test Name");
+ expect(rule.name).toBe("Test Name");
+ });
+
+ it("allows input of the number 0", () => {
+ rule.setName(0);
+ expect(rule.name).toBe(0);
+ });
+
+ it("allows input of an object", () => {
+ rule.setName({
+ id: 123,
+ name: "myRule",
+ });
+ expect(rule.name).toEqual({
+ id: 123,
+ name: "myRule",
+ });
+ });
+
+ it("errors if name is an empty string", () => {
+ expect(rule.setName.bind(null, "")).toThrow(
+ /Rule "name" must be defined/,
+ );
+ });
+ });
+
+ describe("priotizeConditions()", () => {
+ const conditions = [
+ {
+ fact: "age",
+ operator: "greaterThanInclusive",
+ value: 18,
+ },
+ {
+ fact: "segment",
+ operator: "equal",
+ value: "human",
+ },
+ {
+ fact: "accountType",
+ operator: "equal",
+ value: "admin",
+ },
+ {
+ fact: "state",
+ operator: "equal",
+ value: "admin",
+ },
+ ];
+
+ it("orders based on priority", async () => {
+ const engine = new Engine();
+ engine.addFact("state", async () => {}, { priority: 500 });
+ engine.addFact("segment", async () => {}, { priority: 50 });
+ engine.addFact("accountType", async () => {}, { priority: 25 });
+ engine.addFact("age", async () => {}, { priority: 100 });
+ const rule = new Rule();
+ rule.setEngine(engine);
+
+ const prioritizedConditions = rule.prioritizeConditions(conditions);
+ expect(prioritizedConditions.length).toBe(4);
+ expect(prioritizedConditions[0][0].fact).toBe("state");
+ expect(prioritizedConditions[1][0].fact).toBe("age");
+ expect(prioritizedConditions[2][0].fact).toBe("segment");
+ expect(prioritizedConditions[3][0].fact).toBe("accountType");
+ });
+ });
+
+ describe("evaluate()", () => {
+ function setup() {
+ const engine = new Engine();
+ const rule = new Rule();
+ rule.setConditions({
+ all: [],
+ });
+ engine.addRule(rule);
+
+ return { engine, rule };
+ }
+ it("evalutes truthy when there are no conditions", async () => {
+ const engineSuccessSpy = vi.fn();
+ const { engine } = setup();
+
+ engine.on("success", engineSuccessSpy);
+
+ await engine.run();
+
+ expect(engineSuccessSpy).toHaveBeenCalledOnce();
+ });
+
+ it('waits for all on("success") event promises to be resolved', async () => {
+ const engineSuccessSpy = vi.fn();
+ const ruleSuccessSpy = vi.fn();
+ const engineRunSpy = vi.fn();
+ const { engine, rule } = setup();
+ rule.on("success", () => {
+ return new Promise(function (resolve) {
+ setTimeout(function () {
+ ruleSuccessSpy();
+ resolve();
+ }, 5);
+ });
+ });
+ engine.on("success", engineSuccessSpy);
+
+ await engine.run().then(() => engineRunSpy());
+
+ expect(ruleSuccessSpy).toHaveBeenCalledOnce();
+ expect(engineSuccessSpy).toHaveBeenCalledOnce();
+ expect(Math.min(...ruleSuccessSpy.mock.invocationCallOrder)).toBeLessThan(
+ Math.min(...engineRunSpy.mock.invocationCallOrder),
+ );
+ expect(Math.min(...ruleSuccessSpy.mock.invocationCallOrder)).toBeLessThan(
+ Math.min(...engineSuccessSpy.mock.invocationCallOrder),
+ );
+ });
+ });
+
+ describe("toJSON() and fromJSON()", () => {
+ const priority = 50;
+ const event = {
+ type: "to-json!",
+ params: { id: 1 },
+ };
+ const conditions = {
+ priority: 1,
+ all: [
+ {
+ value: 10,
+ operator: "equals",
+ fact: "user",
+ params: {
+ foo: true,
+ },
+ path: "$.id",
+ },
+ ],
+ };
+ const name = "testName";
+ let rule;
+ beforeEach(() => {
+ rule = new Rule();
+ rule.setConditions(conditions);
+ rule.setPriority(priority);
+ rule.setEvent(event);
+ rule.setName(name);
+ });
+
+ it("serializes itself", () => {
+ const json = rule.toJSON(false);
+ expect(Object.keys(json).length).toBe(4);
+ expect(json.conditions).toEqual(conditions);
+ expect(json.priority).toBe(priority);
+ expect(json.event).toEqual(event);
+ expect(json.name).toBe(name);
+ });
+
+ it("serializes itself as json", () => {
+ const jsonString = rule.toJSON();
+ expect(jsonString).toBeTypeOf("string");
+ const json = JSON.parse(jsonString);
+ expect(Object.keys(json).length).toBe(4);
+ expect(json.conditions).toEqual(conditions);
+ expect(json.priority).toBe(priority);
+ expect(json.event).toEqual(event);
+ expect(json.name).toBe(name);
+ });
+
+ it("rehydrates itself using a JSON string", () => {
+ const jsonString = rule.toJSON();
+ expect(jsonString).toBeTypeOf("string");
+ const hydratedRule = new Rule(jsonString);
+ expect(hydratedRule.conditions).toEqual(rule.conditions);
+ expect(hydratedRule.priority).toBe(rule.priority);
+ expect(hydratedRule.ruleEvent).toEqual(rule.ruleEvent);
+ expect(hydratedRule.name).toBe(rule.name);
+ });
+
+ it("rehydrates itself using an object from JSON.parse()", () => {
+ const jsonString = rule.toJSON();
+ expect(jsonString).toBeTypeOf("string");
+ const json = JSON.parse(jsonString);
+ const hydratedRule = new Rule(json);
+ expect(hydratedRule.conditions).toEqual(rule.conditions);
+ expect(hydratedRule.priority).toBe(rule.priority);
+ expect(hydratedRule.ruleEvent).toEqual(rule.ruleEvent);
+ expect(hydratedRule.name).toBe(rule.name);
+ });
+ });
+});
diff --git a/test/support/bootstrap.js b/test/support/bootstrap.js
deleted file mode 100644
index a80b80af..00000000
--- a/test/support/bootstrap.js
+++ /dev/null
@@ -1,14 +0,0 @@
-'use strict'
-
-const chai = require('chai')
-const sinonChai = require('sinon-chai')
-const chaiAsPromised = require('chai-as-promised')
-const dirtyChai = require('dirty-chai')
-chai.use(chaiAsPromised)
-chai.use(sinonChai)
-chai.use(dirtyChai)
-global.expect = chai.expect
-global.factories = {
- rule: require('./rule-factory'),
- condition: require('./condition-factory')
-}
diff --git a/test/support/condition-factory.js b/test/support/condition-factory.js
deleted file mode 100644
index 3546ed13..00000000
--- a/test/support/condition-factory.js
+++ /dev/null
@@ -1,9 +0,0 @@
-'use strict'
-
-module.exports = function (options) {
- return {
- fact: options.fact || null,
- value: options.value || null,
- operator: options.operator || 'equal'
- }
-}
diff --git a/test/support/condition-factory.mjs b/test/support/condition-factory.mjs
new file mode 100644
index 00000000..d3bff432
--- /dev/null
+++ b/test/support/condition-factory.mjs
@@ -0,0 +1,7 @@
+export default function (options) {
+ return {
+ fact: options.fact || null,
+ value: options.value || null,
+ operator: options.operator || "equal",
+ };
+}
diff --git a/test/support/rule-factory.js b/test/support/rule-factory.js
deleted file mode 100644
index f9467805..00000000
--- a/test/support/rule-factory.js
+++ /dev/null
@@ -1,28 +0,0 @@
-'use strict'
-
-module.exports = (options) => {
- options = options || {}
- return {
- name: options.name,
- priority: options.priority || 1,
- conditions: options.conditions || {
- all: [{
- fact: 'age',
- operator: 'lessThan',
- value: 45
- },
- {
- fact: 'pointBalance',
- operator: 'greaterThanInclusive',
- value: 1000
- }]
- },
- event: options.event || {
- type: 'pointCapReached',
- params: {
- currency: 'points',
- pointCap: 1000
- }
- }
- }
-}
diff --git a/test/support/rule-factory.mjs b/test/support/rule-factory.mjs
new file mode 100644
index 00000000..a63b12c5
--- /dev/null
+++ b/test/support/rule-factory.mjs
@@ -0,0 +1,28 @@
+export default (options) => {
+ options = options || {};
+ return {
+ name: options.name,
+ priority: options.priority || 1,
+ conditions: options.conditions || {
+ all: [
+ {
+ fact: "age",
+ operator: "lessThan",
+ value: 45,
+ },
+ {
+ fact: "pointBalance",
+ operator: "greaterThanInclusive",
+ value: 1000,
+ },
+ ],
+ },
+ event: options.event || {
+ type: "pointCapReached",
+ params: {
+ currency: "points",
+ pointCap: 1000,
+ },
+ },
+ };
+};
diff --git a/test/types.test-d.mts b/test/types.test-d.mts
new file mode 100644
index 00000000..86c3f5a1
--- /dev/null
+++ b/test/types.test-d.mts
@@ -0,0 +1,217 @@
+import { describe, it, expectTypeOf } from "vitest";
+
+import rulesEngine, {
+ Almanac,
+ EngineResult,
+ Engine,
+ Event,
+ Fact,
+ Operator,
+ OperatorEvaluator,
+ OperatorDecorator,
+ OperatorDecoratorEvaluator,
+ PathResolver,
+ Rule,
+ RuleProperties,
+ RuleResult,
+ RuleSerializable,
+} from "../types/index.js";
+
+// setup basic fixture data
+const ruleProps: RuleProperties = {
+ conditions: {
+ all: [],
+ },
+ event: {
+ type: "message",
+ },
+};
+
+const complexRuleProps: RuleProperties = {
+ conditions: {
+ all: [
+ {
+ any: [
+ {
+ all: [],
+ },
+ {
+ fact: "foo",
+ operator: "equal",
+ value: "bar",
+ },
+ ],
+ },
+ ],
+ },
+ event: {
+ type: "message",
+ },
+};
+
+describe("type tests", () => {
+ it("path resolver type", () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const pathResolver = function (_value: object, _path: string): any {};
+ expectTypeOf(pathResolver);
+ });
+
+ it("default export", () => {
+ expectTypeOf(rulesEngine([ruleProps]));
+ });
+
+ const engine = rulesEngine([complexRuleProps]);
+
+ it("engine run returns a promise of the result", () => {
+ expectTypeOf>(engine.run({ displayMessage: true }));
+ });
+
+ describe("rule tests", () => {
+ const rule = new Rule(ruleProps);
+ const ruleFromString: Rule = new Rule(JSON.stringify(ruleProps));
+
+ it("returns the engine when adding a rule", () => {
+ expectTypeOf(engine.addRule(rule));
+ });
+
+ it("returns boolean when removing a rule", () => {
+ expectTypeOf(engine.removeRule(ruleFromString));
+ });
+
+ it("returns void when updating a rule", () => {
+ expectTypeOf(engine.updateRule(ruleFromString));
+ });
+
+ it("returns rule when setting conditions", () => {
+ expectTypeOf(rule.setConditions({ any: [] }));
+ });
+
+ it("returns rule when setting event", () => {
+ expectTypeOf(rule.setEvent({ type: "test" }));
+ });
+
+ it("returns rule when setting priority", () => {
+ expectTypeOf(rule.setPriority(1));
+ });
+
+ it("returns string when json stringifying", () => {
+ expectTypeOf(rule.toJSON());
+ expectTypeOf(rule.toJSON(true));
+ });
+
+ it("returns serializable props when converting to json", () => {
+ expectTypeOf(rule.toJSON(false));
+ });
+ });
+
+ describe("operator tests", () => {
+ const operatorEvaluator: OperatorEvaluator = (
+ a: number,
+ b: number,
+ ) => a === b;
+
+ const operator: Operator = new Operator(
+ "test",
+ operatorEvaluator,
+ (num: number) => num > 0,
+ );
+
+ it("returns void when adding an operatorEvaluator", () => {
+ expectTypeOf(engine.addOperator("test", operatorEvaluator));
+ });
+
+ it("returns void when adding an operator object", () => {
+ expectTypeOf(engine.addOperator(operator));
+ });
+
+ it("returns a boolean when removing an operator", () => {
+ expectTypeOf(engine.removeOperator(operator));
+ });
+ });
+
+ describe("operator decorator tests", () => {
+ const operatorDecoratorEvaluator: OperatorDecoratorEvaluator<
+ number[],
+ number,
+ number,
+ number
+ > = (a: number[], b: number, next: OperatorEvaluator) =>
+ next(a[0], b);
+ const operatorDecorator: OperatorDecorator = new OperatorDecorator(
+ "first",
+ operatorDecoratorEvaluator,
+ (a: number[]) => a.length > 0,
+ );
+
+ it("returns void when adding a decorator evaluator", () => {
+ expectTypeOf(
+ engine.addOperatorDecorator("first", operatorDecoratorEvaluator),
+ );
+ });
+
+ it("returns void when adding a decorator object", () => {
+ expectTypeOf(engine.addOperatorDecorator(operatorDecorator));
+ });
+
+ it("returns a boolean when removing a decorator", () => {
+ expectTypeOf(engine.removeOperatorDecorator(operatorDecorator));
+ });
+ });
+
+ describe("fact tests", () => {
+ const fact = new Fact("test-fact", 3);
+ const dynamicFact = new Fact("test-fact", () => [42]);
+
+ it("returns engine when adding a fact value", () => {
+ expectTypeOf(
+ engine.addFact("test-fact", "value", { priority: 10 }),
+ );
+ });
+
+ it("returns engine when adding a constant fact object", () => {
+ expectTypeOf(engine.addFact(fact));
+ });
+
+ it("returns engine when adding a dynamic fact object", () => {
+ expectTypeOf(engine.addFact(dynamicFact));
+ });
+
+ it("returns boolean when removing a fact", () => {
+ expectTypeOf(engine.removeFact(fact));
+ });
+
+ it("returns fact when getting a fact", () => {
+ expectTypeOf>(engine.getFact("test"));
+ });
+ });
+
+ describe("almanac tests", () => {
+ const almanac: Almanac = new Almanac();
+
+ it("factValue returns promise of value", () => {
+ expectTypeOf>(almanac.factValue("test-fact"));
+ });
+
+ it("addRuntimeFact returns void", () => {
+ expectTypeOf(almanac.addRuntimeFact("test-fact", "some-value"));
+ });
+ });
+
+ describe("event tests", () => {
+ it("standard event has event, almanac, and ruleResult", () => {
+ engine.on("success", (event, almanac, ruleResult) => {
+ expectTypeOf(event);
+ expectTypeOf(almanac);
+ expectTypeOf(ruleResult);
+ });
+ });
+
+ it("custom event type has custom type, almanac, and ruleResult", () => {
+ engine.on<{ foo: Array }>("foo", (event, almanac, ruleResult) => {
+ expectTypeOf<{ foo: Array }>(event);
+ expectTypeOf(almanac);
+ expectTypeOf(ruleResult);
+ });
+ });
+ });
+});
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 00000000..9e58401c
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,113 @@
+{
+ "compilerOptions": {
+ /* Visit https://aka.ms/tsconfig to read more about this file */
+
+ /* Projects */
+ // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
+ // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
+ // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
+ // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
+ // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
+ // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
+
+ /* Language and Environment */
+ "target": "ESNext" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
+ // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
+ // "jsx": "preserve", /* Specify what JSX code is generated. */
+ // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
+ // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
+ // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
+ // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
+ // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
+ // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
+ // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
+ // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
+ // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
+
+ /* Modules */
+ "module": "ESNext",
+ // "rootDir": "./", /* Specify the root folder within your source files. */
+ "moduleResolution": "Node", /* Specify how TypeScript looks up a file from a given module specifier. */
+ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
+ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
+ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
+ // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
+ // "types": [], /* Specify type package names to be included without being referenced in a source file. */
+ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
+ // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
+ // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
+ // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
+ // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
+ // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
+ // "noUncheckedSideEffectImports": true, /* Check side effect imports. */
+ // "resolveJsonModule": true, /* Enable importing .json files. */
+ // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
+ // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */
+
+ /* JavaScript Support */
+ // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
+ // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
+ // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
+
+ /* Emit */
+ // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
+ // "declarationMap": true, /* Create sourcemaps for d.ts files. */
+ // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
+ // "sourceMap": true, /* Create source map files for emitted JavaScript files. */
+ // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
+ // "noEmit": true, /* Disable emitting files from a compilation. */
+ // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
+ // "outDir": "./", /* Specify an output folder for all emitted files. */
+ // "removeComments": true, /* Disable emitting comments. */
+ // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
+ // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
+ // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
+ // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
+ // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
+ // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
+ // "newLine": "crlf", /* Set the newline character for emitting files. */
+ // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
+ // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
+ // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
+ // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
+ // "declarationDir": "./", /* Specify the output directory for generated declaration files. */
+
+ /* Interop Constraints */
+ // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
+ // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
+ // "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
+ // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
+ "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
+ // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
+ "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
+
+ /* Type Checking */
+ "strict": true /* Enable all strict type-checking options. */,
+ // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
+ // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
+ // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
+ // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
+ // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
+ // "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
+ // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
+ // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
+ // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
+ // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
+ // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
+ // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
+ // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
+ // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
+ // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
+ // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
+ // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
+ // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
+ // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
+
+ /* Completeness */
+ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
+ "skipLibCheck": true /* Skip type checking all .d.ts files. */
+ },
+ "exclude": [
+ "tsup.config.ts"
+ ]
+}
diff --git a/tsup.config.ts b/tsup.config.ts
new file mode 100644
index 00000000..4f77f3ce
--- /dev/null
+++ b/tsup.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from "tsup";
+
+export default defineConfig({
+ entry: ["src/index.mjs"],
+ sourcemap: true,
+ format: ["esm", "cjs"],
+ target: ["es2015"],
+ cjsInterop: true,
+});
diff --git a/types/index.d.ts b/types/index.d.ts
index 81f08dcb..bf3fd2c6 100644
--- a/types/index.d.ts
+++ b/types/index.d.ts
@@ -1,3 +1,5 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
export interface AlmanacOptions {
allowUndefinedFacts?: boolean;
pathResolver?: PathResolver;
@@ -22,7 +24,7 @@ export interface EngineResult {
export default function engineFactory(
rules: Array,
- options?: EngineOptions
+ options?: EngineOptions,
): Engine;
export class Engine {
@@ -38,26 +40,32 @@ export class Engine {
addOperator(operator: Operator): void;
addOperator(
operatorName: string,
- callback: OperatorEvaluator
+ callback: OperatorEvaluator,
): void;
removeOperator(operator: Operator | string): boolean;
addOperatorDecorator(decorator: OperatorDecorator): void;
- addOperatorDecorator(decoratorName: string, callback: OperatorDecoratorEvaluator): void;
+ addOperatorDecorator(
+ decoratorName: string,
+ callback: OperatorDecoratorEvaluator,
+ ): void;
removeOperatorDecorator(decorator: OperatorDecorator | string): boolean;
addFact(fact: Fact): this;
addFact(
id: string,
valueCallback: DynamicFactCallback | T,
- options?: FactOptions
+ options?: FactOptions,
): this;
removeFact(factOrId: string | Fact): boolean;
getFact(factId: string): Fact;
on(eventName: string, handler: EventHandler): this;
- run(facts?: Record, runOptions?: RunOptions): Promise;
+ run(
+ facts?: Record,
+ runOptions?: RunOptions,
+ ): Promise;
stop(): this;
}
@@ -70,21 +78,30 @@ export class Operator {
constructor(
name: string,
evaluator: OperatorEvaluator,
- validator?: (factValue: A) => boolean
+ validator?: (factValue: A) => boolean,
);
}
export interface OperatorDecoratorEvaluator {
- (factValue: A, compareToValue: B, next: OperatorEvaluator): boolean
-}
-
-export class OperatorDecorator {
+ (
+ factValue: A,
+ compareToValue: B,
+ next: OperatorEvaluator,
+ ): boolean;
+}
+
+export class OperatorDecorator<
+ A = unknown,
+ B = unknown,
+ NextA = unknown,
+ NextB = unknown,
+> {
public name: string;
constructor(
name: string,
evaluator: OperatorDecoratorEvaluator,
- validator?: (factValue: A) => boolean
- )
+ validator?: (factValue: A) => boolean,
+ );
}
export class Almanac {
@@ -92,13 +109,13 @@ export class Almanac {
factValue(
factId: string,
params?: Record,
- path?: string
+ path?: string,
): Promise;
addFact(fact: Fact): this;
addFact(
id: string,
valueCallback: DynamicFactCallback | T,
- options?: FactOptions
+ options?: FactOptions,
): this;
addRuntimeFact(factId: string, value: any): void;
}
@@ -110,7 +127,7 @@ export type FactOptions = {
export type DynamicFactCallback = (
params: Record,
- almanac: Almanac
+ almanac: Almanac,
) => T;
export class Fact {
@@ -123,7 +140,7 @@ export class Fact {
constructor(
id: string,
value: T | DynamicFactCallback,
- options?: FactOptions
+ options?: FactOptions,
);
}
@@ -137,7 +154,7 @@ export type PathResolver = (value: object, path: string) => any;
export type EventHandler = (
event: T,
almanac: Almanac,
- ruleResult: RuleResult
+ ruleResult: RuleResult,
) => void;
export interface RuleProperties {
@@ -172,7 +189,7 @@ export class Rule implements RuleProperties {
setPriority(priority: number): this;
toJSON(): string;
toJSON(
- stringify: T
+ stringify: T,
): T extends true ? string : RuleSerializable;
}
diff --git a/types/index.test-d.ts b/types/index.test-d.ts
deleted file mode 100644
index 6d5b37b1..00000000
--- a/types/index.test-d.ts
+++ /dev/null
@@ -1,135 +0,0 @@
-import { expectType } from "tsd";
-
-import rulesEngine, {
- Almanac,
- EngineResult,
- Engine,
- Event,
- Fact,
- Operator,
- OperatorEvaluator,
- OperatorDecorator,
- OperatorDecoratorEvaluator,
- PathResolver,
- Rule,
- RuleProperties,
- RuleResult,
- RuleSerializable
-} from "../";
-
-// setup basic fixture data
-const ruleProps: RuleProperties = {
- conditions: {
- all: []
- },
- event: {
- type: "message"
- }
-};
-
-const complexRuleProps: RuleProperties = {
- conditions: {
- all: [
- {
- any: [
- {
- all: []
- },
- {
- fact: "foo",
- operator: "equal",
- value: "bar"
- }
- ]
- }
- ]
- },
- event: {
- type: "message"
- }
-};
-
-// path resolver
-const pathResolver = function(value: object, path: string): any {}
-expectType(pathResolver)
-
-// default export test
-expectType(rulesEngine([ruleProps]));
-const engine = rulesEngine([complexRuleProps]);
-
-// Rule tests
-const rule: Rule = new Rule(ruleProps);
-const ruleFromString: Rule = new Rule(JSON.stringify(ruleProps));
-expectType(engine.addRule(rule));
-expectType(engine.removeRule(ruleFromString));
-expectType(engine.updateRule(ruleFromString));
-
-expectType(rule.setConditions({ any: [] }));
-expectType(rule.setEvent({ type: "test" }));
-expectType(rule.setPriority(1));
-expectType(rule.toJSON());
-expectType(rule.toJSON(true));
-expectType(rule.toJSON(false));
-
-// Operator tests
-const operatorEvaluator: OperatorEvaluator = (
- a: number,
- b: number
-) => a === b;
-expectType(
- engine.addOperator("test", operatorEvaluator)
-);
-const operator: Operator = new Operator(
- "test",
- operatorEvaluator,
- (num: number) => num > 0
-);
-expectType(engine.addOperator(operator));
-expectType(engine.removeOperator(operator));
-
-// Operator Decorator tests
-const operatorDecoratorEvaluator: OperatorDecoratorEvaluator = (
- a: number[],
- b: number,
- next: OperatorEvaluator
-) => next(a[0], b);
-expectType(
- engine.addOperatorDecorator("first", operatorDecoratorEvaluator)
-);
-const operatorDecorator: OperatorDecorator = new OperatorDecorator(
- "first",
- operatorDecoratorEvaluator,
- (a: number[]) => a.length > 0
-);
-expectType(engine.addOperatorDecorator(operatorDecorator));
-expectType(engine.removeOperatorDecorator(operatorDecorator));
-
-// Fact tests
-const fact = new Fact("test-fact", 3);
-const dynamicFact = new Fact("test-fact", () => [42]);
-expectType(
- engine.addFact("test-fact", "value", { priority: 10 })
-);
-expectType(engine.addFact(fact));
-expectType(engine.addFact(dynamicFact));
-expectType(engine.removeFact(fact));
-expectType>(engine.getFact("test"));
-engine.on('success', (event, almanac, ruleResult) => {
- expectType(event)
- expectType(almanac)
- expectType(ruleResult)
-})
-engine.on<{ foo: Array }>('foo', (event, almanac, ruleResult) => {
- expectType<{ foo: Array }>(event)
- expectType(almanac)
- expectType(ruleResult)
-})
-
-// Run the Engine
-expectType>(engine.run({ displayMessage: true }));
-
-// Alamanac tests
-const almanac: Almanac = (await engine.run()).almanac;
-
-expectType>(almanac.factValue("test-fact"));
-expectType(almanac.addRuntimeFact("test-fact", "some-value"));