Skip to content

Commit

Permalink
fix bugs with subscribers and named fact maps
Browse files Browse the repository at this point in the history
  • Loading branch information
akmjenkins committed Sep 7, 2021
1 parent e87b6c7 commit 0fe3cde
Show file tree
Hide file tree
Showing 8 changed files with 224 additions and 22 deletions.
6 changes: 3 additions & 3 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ module.exports = {
collectCoverageFrom: ['./src/*.js'],
coverageThreshold: {
global: {
branches: 75,
functions: 85,
lines: 85,
branches: 80,
functions: 90,
lines: 90,
},
},
};
2 changes: 1 addition & 1 deletion src/engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};

Expand Down
18 changes: 12 additions & 6 deletions src/fact.map.processor.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@ 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
let error = false;
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;
Expand All @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
22 changes: 12 additions & 10 deletions src/rule.runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
});

Expand Down Expand Up @@ -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;
})
Expand Down
25 changes: 25 additions & 0 deletions test/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
160 changes: 160 additions & 0 deletions test/events.test.ts
Original file line number Diff line number Diff line change
@@ -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',
},
}),
},
},
}),
);
});
});
3 changes: 1 addition & 2 deletions test/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) };
};
};

0 comments on commit 0fe3cde

Please sign in to comment.