diff --git a/jest.config.js b/jest.config.js index 5919d62..d706d37 100644 --- a/jest.config.js +++ b/jest.config.js @@ -7,9 +7,9 @@ module.exports = { collectCoverageFrom: ['./src/*.js'], coverageThreshold: { global: { - branches: 75, - functions: 85, - lines: 85, + branches: 80, + functions: 90, + lines: 90, }, }, }; diff --git a/src/engine.js b/src/engine.js index 200438d..319e86c 100644 --- a/src/engine.js +++ b/src/engine.js @@ -16,7 +16,7 @@ export const createRulesEngine = ( const on = (event, subscriber) => { const set = eventMap.get(event); - set ? eventMap.set(event, new Set([subscriber])) : set.add(subscriber); + set ? set.add(subscriber) : eventMap.set(event, new Set([subscriber])); return () => eventMap.get(event).delete(subscriber); }; diff --git a/src/fact.map.processor.js b/src/fact.map.processor.js index 0cfbf23..579938e 100644 --- a/src/fact.map.processor.js +++ b/src/fact.map.processor.js @@ -2,8 +2,8 @@ import { createEvaluator } from './evaluator'; export const createFactMapProcessor = (validator, opts, emit) => (rule) => { const evaluator = createEvaluator(validator, opts, emit, rule); - return async (factMap, id) => { - emit('debug', { type: 'STARTING_FACT_MAP', rule, mapId: id }); + return async (factMap, mapId) => { + emit('debug', { type: 'STARTING_FACT_MAP', rule, mapId, factMap }); // flags for if there was an error processing the fact map // and if all evaluations in the fact map passed @@ -11,7 +11,7 @@ export const createFactMapProcessor = (validator, opts, emit) => (rule) => { let passed = true; const results = ( - await Promise.all(Object.entries(factMap).map(evaluator(id))) + await Promise.all(Object.entries(factMap).map(evaluator(mapId))) ).reduce((acc, { factName, ...rest }) => { if (error) return acc; error = error || !!rest.error; @@ -20,11 +20,17 @@ export const createFactMapProcessor = (validator, opts, emit) => (rule) => { return acc; }, {}); - emit('debug', { type: 'FINISHED_FACT_MAP', rule, mapId: id, results }); + emit('debug', { + type: 'FINISHED_FACT_MAP', + rule, + mapId, + results, + passed, + error, + }); - // return the results in the same form they were passed in return { - [id]: { + [mapId]: { ...results, __passed: passed, __error: error, diff --git a/src/index.d.ts b/src/index.d.ts index 1781444..41f5c4a 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -77,6 +77,16 @@ type StartingFactMapEvent = { type: 'STARTING_FACT_MAP'; rule: string; mapId: string | number; + factMap: FactMap; +}; + +type FinishedFactMapEvent = { + type: 'FINISHED_FACT_MAP'; + rule: string; + mapId: string | number; + results: FactMapResult; + passed: boolean; + error: boolean; }; type StartingFactEvent = { diff --git a/src/rule.runner.js b/src/rule.runner.js index 6e748df..1a29ff8 100644 --- a/src/rule.runner.js +++ b/src/rule.runner.js @@ -27,34 +27,36 @@ export const createRuleRunner = (validator, opts, emit) => { const ruleResults = await Promise.all( Array.isArray(interpolated) ? interpolated.map(process) - : Object.entries(interpolated).map(([factMap, id]) => - process(factMap, id), - ), + : Object.entries(interpolated).map(async ([k, v]) => process(v, k)), ); // create the context and evaluate whether the rules have passed or errored in a single loop - const { passed, error, context } = ruleResults.reduce( - ({ passed, error, context }, result) => { + const { passed, error, resultsContext } = ruleResults.reduce( + ({ passed, error, resultsContext }, result) => { if (error) return { error }; passed = passed && Object.values(result).every(({ __passed }) => __passed); error = Object.values(result).some(({ __error }) => __error); - return { passed, error, context: { ...context, ...result } }; + return { + passed, + error, + resultsContext: { ...resultsContext, ...result }, + }; }, { passed: true, error: false, - context: {}, + resultsContext: {}, }, ); - const nextContext = { ...opts.context, results: context }; + const nextContext = { ...opts.context, results: resultsContext }; const ret = (rest = {}) => ({ [rule]: { __error: error, __passed: passed, ...rest, - results: ruleResults, + results: resultsContext, }, }); @@ -93,7 +95,7 @@ export const createRuleRunner = (validator, opts, emit) => { rule, interpolated, context: opts.context, - result: { actions: actionResults, results: ruleResults }, + result: { actions: actionResults, results: resultsContext }, }); return actionResults; }) diff --git a/test/engine.test.ts b/test/engine.test.ts index afd22f6..37e7188 100644 --- a/test/engine.test.ts +++ b/test/engine.test.ts @@ -40,6 +40,31 @@ describe('rules engine', () => { expect(call).toHaveBeenCalledWith({ message: 'Who are you?' }); }); + it('should execute a rule using a named fact map', async () => { + const rules = { + salutation: { + when: { + myFacts: { + firstName: { is: { type: 'string', pattern: '^J' } }, + }, + }, + then: { + actions: [{ type: 'log', params: { message: 'Hi friend!' } }], + }, + otherwise: { + actions: [{ type: 'call', params: { message: 'Who are you?' } }], + }, + }, + }; + engine.setRules(rules); + await engine.run({ firstName: 'John' }); + expect(log).toHaveBeenCalledWith({ message: 'Hi friend!' }); + log.mockClear(); + await engine.run({ firstName: 'Bill' }); + expect(log).not.toHaveBeenCalled(); + expect(call).toHaveBeenCalledWith({ message: 'Who are you?' }); + }); + it('should process nested rules', async () => { const rules = { salutation: { diff --git a/test/events.test.ts b/test/events.test.ts new file mode 100644 index 0000000..c877485 --- /dev/null +++ b/test/events.test.ts @@ -0,0 +1,160 @@ +import createRulesEngine, { RulesEngine } from '../src'; +import { createAjvValidator } from './validators'; + +describe('events', () => { + let engine: RulesEngine; + let log: jest.Mock; + let call: jest.Mock; + + beforeEach(() => { + log = jest.fn(); + call = jest.fn(); + engine = createRulesEngine(createAjvValidator(), { + actions: { log, call }, + }); + }); + + it('should unsubscribe', async () => { + const rules = { + salutation: { + when: { + myFacts: { + firstName: { is: { type: 'string', pattern: '^J' } }, + }, + }, + then: { + actions: [{ type: 'log', params: { message: 'Hi friend!' } }], + }, + otherwise: { + actions: [{ type: 'call', params: { message: 'Who are you?' } }], + }, + }, + }; + engine.setRules(rules); + const subscriber = jest.fn(); + const unsub = engine.on('debug', subscriber); + await engine.run({ firstName: 'John' }); + expect(subscriber).toHaveBeenCalled(); + unsub(); + subscriber.mockClear(); + await engine.run({ firstName: 'John' }); + expect(subscriber).not.toHaveBeenCalled(); + }); + + it('should subscribe to debug events', async () => { + const rules = { + salutation: { + when: { + myFacts: { + firstName: { is: { type: 'string', pattern: '^J' } }, + }, + }, + then: { + actions: [{ type: 'log', params: { message: 'Hi friend!' } }], + }, + otherwise: { + actions: [{ type: 'call', params: { message: 'Who are you?' } }], + }, + }, + }; + engine.setRules(rules); + const subscriber = jest.fn(); + const context = { firstName: 'John' }; + engine.on('debug', subscriber); + await engine.run(context); + + expect(subscriber).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + type: 'STARTING_RULE', + rule: 'salutation', + interpolated: rules.salutation.when, + context, + }), + ); + + expect(subscriber).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + type: 'STARTING_FACT_MAP', + rule: 'salutation', + mapId: 'myFacts', + factMap: rules.salutation.when.myFacts, + }), + ); + + expect(subscriber).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + type: 'STARTING_FACT', + rule: 'salutation', + mapId: 'myFacts', + factName: 'firstName', + }), + ); + + expect(subscriber).toHaveBeenNthCalledWith( + 4, + expect.objectContaining({ + type: 'EXECUTED_FACT', + rule: 'salutation', + mapId: 'myFacts', + path: undefined, + factName: 'firstName', + value: context.firstName, + resolved: context.firstName, + }), + ); + + expect(subscriber).toHaveBeenNthCalledWith( + 5, + expect.objectContaining({ + type: 'EVALUATED_FACT', + rule: 'salutation', + mapId: 'myFacts', + path: undefined, + factName: 'firstName', + value: 'John', + resolved: 'John', + is: { type: 'string', pattern: '^J' }, + result: { result: true }, + }), + ); + + expect(subscriber).toHaveBeenNthCalledWith( + 6, + expect.objectContaining({ + type: 'FINISHED_FACT_MAP', + rule: 'salutation', + mapId: 'myFacts', + results: { + firstName: { result: true, value: 'John', resolved: 'John' }, + }, + passed: true, + error: false, + }), + ); + + expect(subscriber).toHaveBeenNthCalledWith( + 7, + expect.objectContaining({ + type: 'FINISHED_RULE', + rule: 'salutation', + interpolated: rules.salutation.when, + context, + result: { + actions: [{ type: 'log', params: { message: 'Hi friend!' } }], + results: { + myFacts: expect.objectContaining({ + firstName: { + result: true, + value: 'John', + resolved: 'John', + }, + }), + }, + }, + }), + ); + }); +}); diff --git a/test/validators.ts b/test/validators.ts index fe204ab..930824c 100644 --- a/test/validators.ts +++ b/test/validators.ts @@ -4,7 +4,6 @@ export const createAjvValidator = () => { const ajv = new Ajv2019(); return async (object: any, schema: any) => { const validate = ajv.compile(schema); - const result = validate(object); - return { result, errors: validate.errors }; + return { result: validate(object) }; }; };