diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 673bd33..39f9e87 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -5,9 +5,9 @@ name: Node.js CI on: push: - branches: [ master ] + branches: [ master, next-major ] pull_request: - branches: [ master ] + branches: [ master, next-major ] jobs: build: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c08f5c..a3027ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +#### 6.0. / 2020-12-XX + * BREAKING CHANGES + * Private `rule.event` property renamed. Use `rule.getEvent()` to avoid breaking changes in the future. + * 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. + #### 5.3.0 / 2020-12-02 * Allow facts to have a value of `undefined` diff --git a/docs/engine.md b/docs/engine.md index 23fd467..c96b72d 100644 --- a/docs/engine.md +++ b/docs/engine.md @@ -176,7 +176,7 @@ There are two generic event emissions that trigger automatically: #### ```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). +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) { @@ -186,7 +186,7 @@ engine.on('success', function(event, almanac, 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). +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) { diff --git a/docs/rules.md b/docs/rules.md index 7111553..6110936 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -50,9 +50,9 @@ let rule = new Rule(options) **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. +**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. +**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. @@ -60,14 +60,26 @@ let rule = new Rule(options) Helper for setting rule conditions. Alternative to passing the `conditions` option to the rule constructor. +### getConditions() -> Object + +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. +### getEvent() -> Object + +Retrieves rule event set by constructor or `setEvent()` + ### setPriority(Integer priority = 1) Helper for setting rule priority. Alternative to passing the `priority` option to the rule constructor. +### getPriority() -> Integer + +Retrieves rule priority set by constructor or `setPriority()` + ### toJSON(Boolean stringify = true) Serializes the rule into a JSON string. Often used when persisting rules. @@ -207,7 +219,7 @@ For an example, see [fact-dependency](../examples/04-fact-dependency.js) ### Comparing facts -Sometimes it is necessary to compare facts against others 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 diff --git a/examples/07-rule-chaining.js b/examples/07-rule-chaining.js index 10f6db3..6f9aadc 100644 --- a/examples/07-rule-chaining.js +++ b/examples/07-rule-chaining.js @@ -13,6 +13,7 @@ require('colors') const { Engine } = require('json-rules-engine') +const { getAccountInformation } = require('./support/account-api-client') /** * Setup a new engine @@ -36,8 +37,14 @@ const drinkRule = { }, event: { type: 'drinks-screwdrivers' }, priority: 10, // IMPORTANT! Set a higher priority for the drinkRule, so it runs first - onSuccess: function (event, almanac) { + onSuccess: async function (event, almanac) { almanac.addRuntimeFact('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.addRuntimeFact('accountInfo', accountInfo) }, onFailure: function (event, almanac) { almanac.addRuntimeFact('screwdriverAficionado', false) @@ -60,6 +67,11 @@ const inviteRule = { fact: 'isSociable', operator: 'equal', value: true + }, { + fact: 'accountInfo', + path: '$.company', + operator: 'equal', + value: 'microsoft' }] }, event: { type: 'invite-to-screwdriver-social' }, @@ -70,46 +82,43 @@ engine.addRule(inviteRule) /** * Register listeners with the engine for rule success and failure */ -let facts engine - .on('success', (event, almanac) => { - console.log(facts.accountId + ' DID '.green + 'meet conditions for the ' + event.type.underline + ' rule.') + .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', event => { - console.log(facts.accountId + ' did ' + 'NOT'.red + ' 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 -facts = { accountId: 'washington', drinksOrangeJuice: true, enjoysVodka: true, isSociable: true } -engine - .run(facts) // first run, using washington's facts - .then((results) => { - // access whether washington is a screwdriverAficionado, - // which was determined at runtime via the rules `drinkRules` - return results.almanac.factValue('screwdriverAficionado') - }) - .then(isScrewdriverAficionado => { - console.log(`${facts.accountId} ${isScrewdriverAficionado ? 'IS'.green : 'IS NOT'.red} a screwdriver aficionado`) - }) - .then(() => { - facts = { accountId: 'jefferson', drinksOrangeJuice: true, enjoysVodka: false, isSociable: true } - return engine.run(facts) // second run, using jefferson's facts; facts & evaluation are independent of the first run - }) - .then((results) => { - // access whether jefferson is a screwdriverAficionado, - // which was determined at runtime via the rules `drinkRules` - return results.almanac.factValue('screwdriverAficionado') - }) - .then(isScrewdriverAficionado => { - console.log(`${facts.accountId} ${isScrewdriverAficionado ? 'IS'.green : 'IS NOT'.red} a screwdriver aficionado`) - }) - .catch(console.log) +async function run () { + // 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`) +} + +run().catch(console.log) /* * OUTPUT: * - * washington DID meet conditions for the drinks-screwdrivers rule. - * washington DID meet conditions for the invite-to-screwdriver-social rule. + * 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. diff --git a/package-lock.json b/package-lock.json index 38f9af7..b346ae9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "json-rules-engine", - "version": "5.3.0", + "version": "6.0.0-alpha-3", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -1904,7 +1904,7 @@ }, "clone-response": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz", + "resolved": "http://artifactory.shuttercorp.net/artifactory/api/npm/npm-composite/clone-response/-/clone-response-1.0.2.tgz", "integrity": "sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws=", "dev": true, "requires": { @@ -2142,7 +2142,7 @@ }, "decompress-response": { "version": "3.3.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz", + "resolved": "http://artifactory.shuttercorp.net/artifactory/api/npm/npm-composite/decompress-response/-/decompress-response-3.3.0.tgz", "integrity": "sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=", "dev": true, "requires": { @@ -2947,10 +2947,10 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true }, - "events": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.2.0.tgz", - "integrity": "sha512-/46HWwbfCX2xTawVfkKLGxMifJYQBWMwY1mjywRtb4c9x8l5NP3KoJtnIOiL1hfdRkIuYhETxQlo62IF8tcnlg==" + "eventemitter2": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.3.tgz", + "integrity": "sha512-t0A2msp6BzOf+QAcI6z9XMktLj52OjGQg+8SJH6v5+3uxNpWYRR3wQmfA+6xtMU9kOC59qk9licus5dYcrYkMQ==" }, "expand-brackets": { "version": "0.1.5", @@ -4027,7 +4027,7 @@ }, "is-typedarray": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "resolved": "http://artifactory.shuttercorp.net/artifactory/api/npm/npm-composite/is-typedarray/-/is-typedarray-1.0.0.tgz", "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", "dev": true }, @@ -4090,7 +4090,7 @@ }, "json-buffer": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz", + "resolved": "http://artifactory.shuttercorp.net/artifactory/api/npm/npm-composite/json-buffer/-/json-buffer-3.0.0.tgz", "integrity": "sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=", "dev": true }, @@ -4185,7 +4185,7 @@ }, "lines-and-columns": { "version": "1.1.6", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz", + "resolved": "http://artifactory.shuttercorp.net/artifactory/api/npm/npm-composite/lines-and-columns/-/lines-and-columns-1.1.6.tgz", "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=", "dev": true }, @@ -4249,7 +4249,7 @@ }, "lodash.get": { "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "resolved": "http://artifactory.shuttercorp.net/artifactory/api/npm/npm-composite/lodash.get/-/lodash.get-4.4.2.tgz", "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=", "dev": true }, @@ -5417,7 +5417,7 @@ }, "prepend-http": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz", + "resolved": "http://artifactory.shuttercorp.net/artifactory/api/npm/npm-composite/prepend-http/-/prepend-http-2.0.0.tgz", "integrity": "sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=", "dev": true }, @@ -6127,7 +6127,7 @@ }, "responselike": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz", + "resolved": "http://artifactory.shuttercorp.net/artifactory/api/npm/npm-composite/responselike/-/responselike-1.0.2.tgz", "integrity": "sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec=", "dev": true, "requires": { @@ -7266,7 +7266,7 @@ }, "url-parse-lax": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz", + "resolved": "http://artifactory.shuttercorp.net/artifactory/api/npm/npm-composite/url-parse-lax/-/url-parse-lax-3.0.0.tgz", "integrity": "sha1-FrXK/Afb42dsGxmZF3gj1lA6yww=", "dev": true, "requires": { diff --git a/package.json b/package.json index 8873de2..6cb140d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "json-rules-engine", - "version": "5.3.0", + "version": "6.0.0-alpha-3", "description": "Rules Engine expressed in simple json", "main": "dist/index.js", "types": "types/index.d.ts", @@ -49,6 +49,7 @@ ], "file": "./test/support/bootstrap.js", "checkLeaks": true, + "recursive": true, "globals": [ "expect" ] @@ -82,7 +83,7 @@ }, "dependencies": { "clone": "^2.1.2", - "events": "^3.2.0", + "eventemitter2": "^6.4.3", "hash-it": "^4.0.5", "jsonpath-plus": "^4.0.0", "lodash.isobjectlike": "^4.0.0" diff --git a/src/almanac.js b/src/almanac.js index 30b4625..296ff94 100644 --- a/src/almanac.js +++ b/src/almanac.js @@ -114,7 +114,7 @@ export default class Almanac { .then(factValue => { if (isObjectLike(factValue)) { const pathValue = JSONPath({ path, json: factValue, wrap: false }) - debug(`condition::evaluate extracting object property ${path}, received: ${pathValue}`) + debug(`condition::evaluate extracting object property ${path}, received: ${JSON.stringify(pathValue)}`) return pathValue } else { debug(`condition::evaluate could not compute object path(${path}) of non-object: ${factValue} <${typeof factValue}>; continuing with ${factValue}`) diff --git a/src/condition.js b/src/condition.js index cd6f428..98a7690 100644 --- a/src/condition.js +++ b/src/condition.js @@ -101,7 +101,7 @@ export default class Condition { return almanac.factValue(this.fact, this.params, this.path) .then(leftHandSideValue => { const result = op.evaluate(leftHandSideValue, rightHandSideValue) - debug(`condition::evaluate <${leftHandSideValue} ${this.operator} ${rightHandSideValue}?> (${result})`) + debug(`condition::evaluate <${JSON.stringify(leftHandSideValue)} ${this.operator} ${JSON.stringify(rightHandSideValue)}?> (${result})`) return { result, leftHandSideValue, rightHandSideValue, operator: this.operator } }) }) diff --git a/src/engine.js b/src/engine.js index 4a949ac..e63f22f 100644 --- a/src/engine.js +++ b/src/engine.js @@ -4,7 +4,7 @@ import Fact from './fact' import Rule from './rule' import Operator from './operator' import Almanac from './almanac' -import { EventEmitter } from 'events' +import EventEmitter from 'eventemitter2' import { SuccessEventFact } from './engine-facts' import defaultOperators from './engine-default-operators' import debug from './debug' @@ -40,13 +40,14 @@ class Engine extends EventEmitter { */ addRule (properties) { if (!properties) throw new Error('Engine: addRule() requires options') - if (!Object.prototype.hasOwnProperty.call(properties, 'conditions')) throw new Error('Engine: addRule() argument requires "conditions" property') - if (!Object.prototype.hasOwnProperty.call(properties, 'event')) throw new Error('Engine: addRule() argument requires "event" property') 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) @@ -191,11 +192,12 @@ class Engine extends EventEmitter { return rule.evaluate(almanac).then((ruleResult) => { debug(`engine::run ruleResult:${ruleResult.result}`) if (ruleResult.result) { - this.emit('success', rule.event, almanac, ruleResult) - this.emit(rule.event.type, rule.event.params, almanac, ruleResult) - almanac.factValue('success-events', { event: rule.event }) + return Promise.all([ + almanac.factValue('success-events', { event: ruleResult.event }), + this.emitAsync('success', ruleResult.event, almanac, ruleResult) + ]).then(() => this.emitAsync(ruleResult.event.type, ruleResult.event.params, almanac, ruleResult)) } else { - this.emit('failure', rule.event, almanac, ruleResult) + return this.emitAsync('failure', ruleResult.event, almanac, ruleResult) } }) })) @@ -209,7 +211,6 @@ class Engine extends EventEmitter { */ run (runtimeFacts = {}) { debug('engine::run started') - debug('engine::run runtimeFacts:', runtimeFacts) runtimeFacts['success-events'] = new Fact('success-events', SuccessEventFact(), { cache: false }) this.status = RUNNING const almanac = new Almanac(this.facts, runtimeFacts, { allowUndefinedFacts: this.allowUndefinedFacts }) diff --git a/src/rule.js b/src/rule.js index d34dac0..5db0191 100644 --- a/src/rule.js +++ b/src/rule.js @@ -2,8 +2,8 @@ import Condition from './condition' import RuleResult from './rule-result' -import { EventEmitter } from 'events' import debug from './debug' +import EventEmitter from 'eventemitter2' class Rule extends EventEmitter { /** @@ -86,13 +86,45 @@ class Rule extends EventEmitter { 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') - this.event = { + this.ruleEvent = { type: event.type } - if (event.params) this.event.params = event.params + if (event.params) this.ruleEvent.params = event.params return this } + /** + * returns the event object + * @returns {Object} event + */ + getEvent () { + return this.ruleEvent + } + + /** + * returns the priority + * @returns {Number} priority + */ + getPriority () { + return this.priority + } + + /** + * returns the event object + * @returns {Object} event + */ + getConditions () { + return this.conditions + } + + /** + * returns the engine object + * @returns {Object} engine + */ + getEngine () { + return this.engine + } + /** * Sets the engine to run the rules under * @param {object} engine @@ -107,7 +139,7 @@ class Rule extends EventEmitter { const props = { conditions: this.conditions.toJSON(false), priority: this.priority, - event: this.event, + event: this.ruleEvent, name: this.name } if (stringify) { @@ -148,7 +180,7 @@ class Rule extends EventEmitter { * @return {Promise(RuleResult)} rule evaluation result */ evaluate (almanac) { - const ruleResult = new RuleResult(this.conditions, this.event, this.priority, this.name) + const ruleResult = new RuleResult(this.conditions, this.ruleEvent, this.priority, this.name) /** * Evaluates the rule conditions @@ -262,14 +294,12 @@ class Rule extends EventEmitter { /** * Emits based on rule evaluation result, and decorates ruleResult with 'result' property - * @param {Boolean} result + * @param {RuleResult} ruleResult */ const processResult = (result) => { ruleResult.setResult(result) - - if (result) this.emit('success', ruleResult.event, almanac, ruleResult) - else this.emit('failure', ruleResult.event, almanac, ruleResult) - return ruleResult + const event = result ? 'success' : 'failure' + return this.emitAsync(event, ruleResult.event, almanac, ruleResult).then(() => ruleResult) } if (ruleResult.conditions.any) { diff --git a/test/acceptance/acceptance.js b/test/acceptance/acceptance.js new file mode 100644 index 0000000..01e3f8f --- /dev/null +++ b/test/acceptance/acceptance.js @@ -0,0 +1,176 @@ +'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 engineResult = await engine.run({ baseIndex: 1 }) + + expect(engineResult.events.length).to.equal(2) + expect(engineResult.events[0]).to.deep.equal(event1) + expect(engineResult.events[1]).to.deep.equal(event2) + 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 engineResult = await engine.run({ baseIndex: 1, 'rule-created-fact': '' }) + + expect(engineResult.events.length).to.equal(0) + 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/engine-failure.test.js b/test/engine-failure.test.js index 1f1907b..86b3f23 100644 --- a/test/engine-failure.test.js +++ b/test/engine-failure.test.js @@ -32,7 +32,7 @@ describe('Engine: failure', () => { const failureSpy = sandbox.spy() engine.on('failure', failureSpy) await engine.run() - expect(failureSpy).to.have.been.calledWith(engine.rules[0].event) + expect(failureSpy).to.have.been.calledWith(engine.rules[0].ruleEvent) }) it('does not emit when a rule passes', async () => { diff --git a/test/engine-rule-priority.js b/test/engine-rule-priority.js index c5f7313..df96f99 100644 --- a/test/engine-rule-priority.js +++ b/test/engine-rule-priority.js @@ -3,11 +3,12 @@ import engineFactory from '../src/index' import sinon from 'sinon' -describe('Engine: cache', () => { +describe('Engine: rule priorities', () => { let engine - const event = { type: 'setDrinkingFlag' } - const collegeSeniorEvent = { type: 'isCollegeSenior' } + const highPriorityEvent = { type: 'highPriorityEvent' } + const midPriorityEvent = { type: 'midPriorityEvent' } + const lowestPriorityEvent = { type: 'lowestPriorityEvent' } const conditions = { any: [{ fact: 'age', @@ -28,12 +29,16 @@ describe('Engine: cache', () => { const factSpy = sandbox.stub().returns(22) const eventSpy = sandbox.spy() engine = engineFactory() - const over20 = factories.rule({ conditions, event: collegeSeniorEvent, priority: 50 }) - engine.addRule(over20) - const determineDrinkingAge = factories.rule({ conditions, event, priority: 100 }) - engine.addRule(determineDrinkingAge) - const determineCollegeSenior = factories.rule({ conditions, event: collegeSeniorEvent, priority: 1 }) - engine.addRule(determineCollegeSenior) + + 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) } @@ -54,4 +59,38 @@ describe('Engine: cache', () => { 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-run.test.js b/test/engine-run.test.js index d508dcf..d3282c1 100644 --- a/test/engine-run.test.js +++ b/test/engine-run.test.js @@ -94,7 +94,6 @@ describe('Engine: run', () => { describe('facts updated during run', () => { beforeEach(() => { engine.on('success', (event, almanac, ruleResult) => { - console.log(ruleResult) // Assign unique runtime facts per event almanac.addRuntimeFact(`runtime-fact-${event.type}`, ruleResult.conditions.any[0].value) }) @@ -102,7 +101,6 @@ describe('Engine: run', () => { it('returns an almanac with runtime facts added', () => { return engine.run({ age: 90 }).then(results => { - console.log(results) return Promise.all([ results.almanac.factValue('runtime-fact-generic1'), results.almanac.factValue('runtime-fact-generic2') diff --git a/test/rule.test.js b/test/rule.test.js index fe48e7f..382aead 100644 --- a/test/rule.test.js +++ b/test/rule.test.js @@ -29,7 +29,7 @@ describe('Rule', () => { const rule = new Rule(opts) expect(rule.priority).to.eql(opts.priority) expect(rule.conditions).to.eql(opts.conditions) - expect(rule.event).to.eql(opts.event) + expect(rule.ruleEvent).to.eql(opts.event) expect(rule.name).to.eql(opts.name) }) @@ -51,7 +51,7 @@ describe('Rule', () => { const rule = new Rule(json) expect(rule.priority).to.eql(opts.priority) expect(rule.conditions).to.eql(opts.conditions) - expect(rule.event).to.eql(opts.event) + expect(rule.ruleEvent).to.eql(opts.event) expect(rule.name).to.eql(opts.name) }) }) @@ -119,6 +119,30 @@ describe('Rule', () => { }) }) + 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) @@ -188,17 +212,48 @@ describe('Rule', () => { }) describe('evaluate()', () => { - it('evalutes truthy when there are no conditions', async () => { - const eventSpy = sinon.spy() + function setup () { const engine = new Engine() const rule = new Rule() rule.setConditions({ all: [] }) engine.addRule(rule) - engine.on('success', eventSpy) + + 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(eventSpy).to.have.been.calledOnce() + + 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) }) }) @@ -256,7 +311,7 @@ describe('Rule', () => { const hydratedRule = new Rule(jsonString) expect(hydratedRule.conditions).to.eql(rule.conditions) expect(hydratedRule.priority).to.eql(rule.priority) - expect(hydratedRule.event).to.eql(rule.event) + expect(hydratedRule.ruleEvent).to.eql(rule.ruleEvent) expect(hydratedRule.name).to.eql(rule.name) }) @@ -267,7 +322,7 @@ describe('Rule', () => { const hydratedRule = new Rule(json) expect(hydratedRule.conditions).to.eql(rule.conditions) expect(hydratedRule.priority).to.eql(rule.priority) - expect(hydratedRule.event).to.eql(rule.event) + expect(hydratedRule.ruleEvent).to.eql(rule.ruleEvent) expect(hydratedRule.name).to.eql(rule.name) }) })