Repository: CacheControl/json-rules-engine
Pull request #8 (2016-09-11 02:19:22)

Custom operators
@ctaity @slmmm - implementation of what we were discussing earlier this week.  API turned out to be essentially what we'd discussed - details described in documentation below.   Let me know if you have any feedback.


Patch:
diff --git a/docs/engine.md b/docs/engine.md
index 2f27ea5..a573413 100644
--- a/docs/engine.md
+++ b/docs/engine.md
@@ -49,6 +49,34 @@ let rule = new Rule()
 engine.addRule(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).
+
+```js
+/*
+ * operatorName - operator identifier mentioned in the rule condition
+ * evaluateFunc(factValue, jsonValue) - compares fact result to the condition 'value', returning boolean
+ *    factValue - the value returned from the fact
+ *    jsonValue - the "value" property stored in the condition itself
+ */
+engine.addOperator('startsWithLetter', (factValue, jsonValue) => {
+  if (!factValue.length) return false
+  return factValue[0].toLowerCase() === jsonValue.toLowerCase()
+})
+
+// and to use the operator...
+rule.setConditions({
+  all: [
+    {
+      fact: 'username',
+      operator: 'startsWithLetter' // reference the operator name in the rule
+      value: 'a'
+    }
+  ]
+})
+```
+
 ### engine.run([Object facts], [Object options]) -> Promise (Events)
 
 Runs the rules engine.  Returns a promise which resolves when all rules have been run.
diff --git a/examples/custom-operators.js b/examples/custom-operators.js
new file mode 100644
index 0000000..f69cf1e
--- /dev/null
+++ b/examples/custom-operators.js
@@ -0,0 +1,110 @@
+'use strict'
+
+/*
+ * This example demonstrates using custom operators.
+ *
+ * A custom operator is created for detecting whether the word starts with a particular letter,
+ * and a 'word' fact is defined for providing the test string
+ *
+ * In this example, Facts are passed to run() as constants known at runtime.  For a more
+ * complex example demonstrating asynchronously computed facts, see the fact-dependency example.
+ *
+ * Usage:
+ *   node ./examples/custom-operators.js
+ *
+ * For detailed output:
+ *   DEBUG=json-rules-engine node ./examples/custom-operators.js
+ */
+
+require('colors')
+var Engine = require('../dist').Engine
+
+/**
+ * Setup a new engine
+ */
+var 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()
+})
+
+/**
+ * Add rule for detecting words that start with 'a'
+ */
+var ruleA = {
+  conditions: {
+    all: [{
+      fact: 'word',
+      operator: 'startsWith',
+      value: 'a'
+    }]
+  },
+  event: {
+    type: 'start-with-a'
+  }
+}
+engine.addRule(ruleA)
+
+/*
+ * Add rule for detecting words that start with 'b'
+ */
+var ruleB = {
+  conditions: {
+    all: [{
+      fact: 'word',
+      operator: 'startsWith',
+      value: 'b'
+    }]
+  },
+  event: {
+    type: 'start-with-b'
+  }
+}
+engine.addRule(ruleB)
+
+// utility for printing output
+var printEventType = {
+  'start-with-a': 'start with "a"',
+  'start-with-b': 'start with "b"'
+}
+
+/**
+ * Register listeners with the engine for rule success and failure
+ */
+var facts
+engine
+  .on('success', function (event) {
+    console.log(facts.word + ' DID '.green + printEventType[event.type])
+  })
+  .on('failure', function (rule) {
+    console.log(facts.word + ' did ' + 'NOT'.red + ' ' + printEventType[rule.event.type])
+  })
+
+/**
+ * Each run() of the engine executes on an independent set of facts.  We'll run twice, once per word
+ */
+facts = {
+  word: 'bacon'
+}
+engine
+  .run(facts)  // first run, using 'bacon'
+  .then(function () {
+    facts = {
+      word: 'antelope'
+    }
+    return engine.run(facts) // second run, using 'antelope'
+  })
+  .catch(console.log)
+
+/*
+ * OUTPUT:
+ *
+ * bacon did NOT start with "a"
+ * bacon DID start with "b"
+ * antelope DID start with "a"
+ * antelope did NOT start with "b"
+ */
diff --git a/src/condition.js b/src/condition.js
index 64397b3..0e838f0 100644
--- a/src/condition.js
+++ b/src/condition.js
@@ -27,6 +27,11 @@ export default class Condition {
     }
   }
 
+  /**
+   * 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) {
     let props = {}
     if (this.priority) {
@@ -49,54 +54,19 @@ export default class Condition {
     return props
   }
 
-  validateComparisonValue (comparisonValue) {
-    switch (this.operator) {
-      case 'contains':
-      case 'doesNotContain':
-        return Array.isArray(comparisonValue)
-      case 'lessThan':
-      case 'lessThanInclusive':
-      case 'greaterThan':
-      case 'greaterThanInclusive':
-        return Number.parseFloat(comparisonValue).toString() !== 'NaN'
-      default:
-        return true
-    }
-  }
+  /**
+   * Takes the fact result and compares it to the condition 'value', using the operator
+   * @param   {mixed} comparisonValue - fact result
+   * @param   {Map} operatorMap - map of available operators, keyed by operator name
+   * @returns {Boolean} - evaluation result
+   */
+  evaluate (comparisonValue, operatorMap) {
+    // for any/all, simply comparisonValue that the sub-condition array evaluated truthy
+    if (this.isBooleanOperator()) return comparisonValue === true
 
-  evaluate (comparisonValue) {
-    if (!this.validateComparisonValue(comparisonValue)) {
-      return false
-    }
-    switch (this.operator) {
-      case 'equal':
-        return comparisonValue === this.value
-      case 'notEqual':
-        return comparisonValue !== this.value
-      case 'in':
-        return this.value.includes(comparisonValue)
-      case 'notIn':
-        return !this.value.includes(comparisonValue)
-      case 'contains':
-        return comparisonValue.includes(this.value)
-      case 'doesNotContain':
-        return !comparisonValue.includes(this.value)
-      case 'lessThan':
-        return comparisonValue < this.value
-      case 'lessThanInclusive':
-        return comparisonValue <= this.value
-      case 'greaterThan':
-        return comparisonValue > this.value
-      case 'greaterThanInclusive':
-        return comparisonValue >= this.value
-      // for any/all, simply comparisonValue that the sub-condition array evaluated truthy
-      case 'any':
-        return comparisonValue === true
-      case 'all':
-        return comparisonValue === true
-      default:
-        throw new Error(`Unknown operator: ${this.operator}`)
-    }
+    let op = operatorMap.get(this.operator)
+    if (!op) throw new Error(`Unknown operator: ${this.operator}`)
+    return op.evaluate(comparisonValue, this.value)
   }
 
   /**
@@ -112,10 +82,19 @@ export default class Condition {
     }
   }
 
+  /**
+   * Returns the condition's boolean operator
+   * Instance version of Condition.isBooleanOperator
+   * @returns {string,undefined} - 'any', 'all', or undefined (if not a boolean condition)
+   */
   booleanOperator () {
     return Condition.booleanOperator(this)
   }
 
+  /**
+   * Whether the operator is boolean ('all', 'any')
+   * @returns {Boolean}
+   */
   isBooleanOperator () {
     return Condition.booleanOperator(this) !== undefined
   }
diff --git a/src/engine-default-operators.js b/src/engine-default-operators.js
new file mode 100644
index 0000000..0faac4a
--- /dev/null
+++ b/src/engine-default-operators.js
@@ -0,0 +1,22 @@
+'use strict'
+
+import Operator from './operator'
+
+let 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.includes(a)))
+Operators.push(new Operator('notIn', (a, b) => !b.includes(a)))
+
+Operators.push(new Operator('contains', (a, b) => a.includes(b), Array.isArray))
+Operators.push(new Operator('doesNotContain', (a, b) => !a.includes(b), 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
index 69309b6..c399c7f 100644
--- a/src/engine.js
+++ b/src/engine.js
@@ -3,9 +3,11 @@
 import params from 'params'
 import Fact from './fact'
 import Rule from './rule'
+import Operator from './operator'
 import Almanac from './almanac'
 import { EventEmitter } from 'events'
 import { SuccessEventFact } from './engine-facts'
+import defaultOperators from './engine-default-operators'
 
 let debug = require('debug')('json-rules-engine')
 
@@ -21,9 +23,11 @@ class Engine extends EventEmitter {
   constructor (rules = []) {
     super()
     this.rules = []
-    rules.map(r => this.addRule(r))
+    this.operators = new Map()
     this.facts = new Map()
     this.status = READY
+    rules.map(r => this.addRule(r))
+    defaultOperators.map(o => this.addOperator(o))
   }
 
   /**
@@ -51,6 +55,22 @@ class Engine extends EventEmitter {
     return this
   }
 
+  /**
+   * 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) {
+    debug(`engine::addOperator name:${operatorOrName}`)
+    let operator
+    if (operatorOrName instanceof Operator) {
+      operator = operatorOrName
+    } else {
+      operator = new Operator(operatorOrName, cb)
+    }
+    this.operators.set(operator.name, operator)
+  }
+
   /**
    * 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
diff --git a/src/json-rules-engine.js b/src/json-rules-engine.js
index c27082d..c0b8652 100644
--- a/src/json-rules-engine.js
+++ b/src/json-rules-engine.js
@@ -1,8 +1,9 @@
 import Engine from './engine'
 import Fact from './fact'
 import Rule from './rule'
+import Operator from './operator'
 
-export { Fact, Rule, Engine }
+export { Fact, Rule, Operator, Engine }
 export default function (rules) {
   return new Engine(rules)
 }
diff --git a/src/operator.js b/src/operator.js
new file mode 100644
index 0000000..e342ced
--- /dev/null
+++ b/src/operator.js
@@ -0,0 +1,29 @@
+'use strict'
+
+export default class Operator {
+  /**
+   * Constructor
+   * @param {string}   name - operator identifier
+   * @param {function(factValue, jsonValue)} callback - operator evaluation method
+   * @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
+  }
+
+  /**
+   * Takes the fact result and compares it to the condition 'value', using the callback
+   * @param   {mixed} factValue - fact result
+   * @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)
+  }
+}
diff --git a/src/rule.js b/src/rule.js
index a797819..b874188 100644
--- a/src/rule.js
+++ b/src/rule.js
@@ -137,7 +137,7 @@ class Rule {
         comparisonValue = await almanac.factValue(condition.fact, condition.params)
       }
 
-      let conditionResult = condition.evaluate(comparisonValue)
+      let conditionResult = condition.evaluate(comparisonValue, this.engine.operators)
       if (!condition.isBooleanOperator()) {
         debug(`evaluateConditions:: <${comparisonValue} ${condition.operator} ${condition.value}?> (${conditionResult})`)
       }


Test patch:
diff --git a/test/condition.test.js b/test/condition.test.js
index 1f4d7d0..2f32b8b 100644
--- a/test/condition.test.js
+++ b/test/condition.test.js
@@ -1,6 +1,10 @@
 'use strict'
 
 import Condition from '../src/condition'
+import defaultOperators from '../src/engine-default-operators'
+
+let operators = new Map()
+defaultOperators.forEach(o => operators.set(o.name, o))
 
 function condition () {
   return {
@@ -27,14 +31,14 @@ describe('Condition', () => {
 
     it('evaluates "equal"', () => {
       setup({ operator: 'equal' })
-      expect(condition.evaluate(50)).to.equal(true)
-      expect(condition.evaluate(5)).to.equal(false)
+      expect(condition.evaluate(50, operators)).to.equal(true)
+      expect(condition.evaluate(5, operators)).to.equal(false)
     })
 
     it('evaluates "notEqual"', () => {
       setup({ operator: 'notEqual' })
-      expect(condition.evaluate(50)).to.equal(false)
-      expect(condition.evaluate(5)).to.equal(true)
+      expect(condition.evaluate(50, operators)).to.equal(false)
+      expect(condition.evaluate(5, operators)).to.equal(true)
     })
 
     it('evaluates "in"', () => {
@@ -42,8 +46,8 @@ describe('Condition', () => {
         operator: 'in',
         value: [5, 10, 15, 20]
       })
-      expect(condition.evaluate(15)).to.equal(true)
-      expect(condition.evaluate(99)).to.equal(false)
+      expect(condition.evaluate(15, operators)).to.equal(true)
+      expect(condition.evaluate(99, operators)).to.equal(false)
     })
 
     it('evaluates "contains"', () => {
@@ -51,8 +55,8 @@ describe('Condition', () => {
         operator: 'contains',
         value: 10
       })
-      expect(condition.evaluate([5, 10, 15])).to.equal(true)
-      expect(condition.evaluate([1, 2, 3])).to.equal(false)
+      expect(condition.evaluate([5, 10, 15], operators)).to.equal(true)
+      expect(condition.evaluate([1, 2, 3], operators)).to.equal(false)
     })
 
     it('evaluates "doesNotContain"', () => {
@@ -60,8 +64,8 @@ describe('Condition', () => {
         operator: 'doesNotContain',
         value: 10
       })
-      expect(condition.evaluate([5, 10, 15])).to.equal(false)
-      expect(condition.evaluate([1, 2, 3])).to.equal(true)
+      expect(condition.evaluate([5, 10, 15], operators)).to.equal(false)
+      expect(condition.evaluate([1, 2, 3], operators)).to.equal(true)
     })
 
     it('evaluates "notIn"', () => {
@@ -69,64 +73,64 @@ describe('Condition', () => {
         operator: 'notIn',
         value: [5, 10, 15, 20]
       })
-      expect(condition.evaluate(15)).to.equal(false)
-      expect(condition.evaluate(99)).to.equal(true)
+      expect(condition.evaluate(15, operators)).to.equal(false)
+      expect(condition.evaluate(99, operators)).to.equal(true)
     })
 
     it('evaluates "lessThan"', () => {
       setup({ operator: 'lessThan' })
-      expect(condition.evaluate(49)).to.equal(true)
-      expect(condition.evaluate(50)).to.equal(false)
-      expect(condition.evaluate(51)).to.equal(false)
+      expect(condition.evaluate(49, operators)).to.equal(true)
+      expect(condition.evaluate(50, operators)).to.equal(false)
+      expect(condition.evaluate(51, operators)).to.equal(false)
     })
 
     it('evaluates "lessThanInclusive"', () => {
       setup({ operator: 'lessThanInclusive' })
-      expect(condition.evaluate(49)).to.equal(true)
-      expect(condition.evaluate(50)).to.equal(true)
-      expect(condition.evaluate(51)).to.equal(false)
+      expect(condition.evaluate(49, operators)).to.equal(true)
+      expect(condition.evaluate(50, operators)).to.equal(true)
+      expect(condition.evaluate(51, operators)).to.equal(false)
     })
     it('evaluates "greaterThan"', () => {
       setup({ operator: 'greaterThan' })
-      expect(condition.evaluate(51)).to.equal(true)
-      expect(condition.evaluate(49)).to.equal(false)
-      expect(condition.evaluate(50)).to.equal(false)
+      expect(condition.evaluate(51, operators)).to.equal(true)
+      expect(condition.evaluate(49, operators)).to.equal(false)
+      expect(condition.evaluate(50, operators)).to.equal(false)
     })
     it('evaluates "greaterThanInclusive"', () => {
       setup({operator: 'greaterThanInclusive'})
-      expect(condition.evaluate(51)).to.equal(true)
-      expect(condition.evaluate(50)).to.equal(true)
-      expect(condition.evaluate(49)).to.equal(false)
+      expect(condition.evaluate(51, operators)).to.equal(true)
+      expect(condition.evaluate(50, operators)).to.equal(true)
+      expect(condition.evaluate(49, operators)).to.equal(false)
     })
 
     describe('invalid comparisonValues', () => {
       it('returns false when using contains or doesNotContain with a non-array', () => {
         setup({operator: 'contains'})
-        expect(condition.evaluate(null)).to.equal(false)
+        expect(condition.evaluate(null, operators)).to.equal(false)
         setup({operator: 'doesNotContain'})
-        expect(condition.evaluate(null)).to.equal(false)
+        expect(condition.evaluate(null, operators)).to.equal(false)
       })
 
       it('returns false when using comparison operators with null', () => {
         setup({operator: 'lessThan'})
-        expect(condition.evaluate(null)).to.equal(false)
+        expect(condition.evaluate(null, operators)).to.equal(false)
         setup({operator: 'lessThanInclusive'})
-        expect(condition.evaluate(null)).to.equal(false)
+        expect(condition.evaluate(null, operators)).to.equal(false)
         setup({operator: 'greaterThan'})
-        expect(condition.evaluate(null)).to.equal(false)
+        expect(condition.evaluate(null, operators)).to.equal(false)
         setup({operator: 'greaterThanInclusive'})
-        expect(condition.evaluate(null)).to.equal(false)
+        expect(condition.evaluate(null, operators)).to.equal(false)
       })
 
       it('returns false when using comparison operators with non-numbers', () => {
         setup({operator: 'lessThan'})
-        expect(condition.evaluate('non-number')).to.equal(false)
+        expect(condition.evaluate('non-number', operators)).to.equal(false)
         setup({operator: 'lessThan'})
-        expect(condition.evaluate(undefined)).to.equal(false)
+        expect(condition.evaluate(undefined, operators)).to.equal(false)
         setup({operator: 'lessThan'})
-        expect(condition.evaluate([])).to.equal(false)
+        expect(condition.evaluate([], operators)).to.equal(false)
         setup({operator: 'lessThan'})
-        expect(condition.evaluate({})).to.equal(false)
+        expect(condition.evaluate({}, operators)).to.equal(false)
       })
     })
   })
diff --git a/test/engine-operator.test.js b/test/engine-operator.test.js
new file mode 100644
index 0000000..0a245ee
--- /dev/null
+++ b/test/engine-operator.test.js
@@ -0,0 +1,64 @@
+'use strict'
+
+import sinon from 'sinon'
+import engineFactory from '../src/index'
+
+async function dictionary (params, engine) {
+  let words = ['coffee', 'Aardvark', 'moose', 'ladder', 'antelope']
+  return words[params.wordIndex]
+}
+
+describe('Engine: operator', () => {
+  let event = {
+    type: 'operatorTrigger'
+  }
+  let baseConditions = {
+    any: [{
+      fact: 'dictionary',
+      operator: 'startsWithLetter',
+      value: 'a',
+      params: {
+        wordIndex: null
+      }
+    }]
+  }
+  let eventSpy = sinon.spy()
+  function setup (conditions = baseConditions) {
+    eventSpy.reset()
+    let engine = engineFactory()
+    let 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 () => {
+      let conditions = Object.assign({}, baseConditions)
+      conditions.any[0].params.wordIndex = 1
+      let engine = setup()
+      await engine.run()
+      expect(eventSpy).to.have.been.calledWith(event)
+    })
+
+    it('does not emit when the condition fails', async () => {
+      let conditions = Object.assign({}, baseConditions)
+      conditions.any[0].params.wordIndex = 0
+      let engine = setup()
+      await engine.run()
+      expect(eventSpy).to.not.have.been.calledWith(event)
+    })
+
+    it('throws when it encounters an unregistered operator', async () => {
+      let conditions = Object.assign({}, baseConditions)
+      conditions.any[0].operator = 'unknown-operator'
+      let engine = setup()
+      expect(engine.run()).to.eventually.throw('Unknown operator: unknown-operator')
+    })
+  })
+})
diff --git a/test/engine.test.js b/test/engine.test.js
index a2c975e..407e183 100644
--- a/test/engine.test.js
+++ b/test/engine.test.js
@@ -4,6 +4,7 @@ import sinon from 'sinon'
 import engineFactory from '../src/index'
 import { Fact } from '../src/index'
 import { Rule } from '../src/index'
+import { Operator } from '../src/index'
 
 describe('Engine', () => {
   let engine
@@ -13,14 +14,17 @@ describe('Engine', () => {
 
   it('has methods for managing facts and rules, and running itself', () => {
     expect(engine).to.have.property('addRule')
+    expect(engine).to.have.property('addOperator')
     expect(engine).to.have.property('addFact')
     expect(engine).to.have.property('run')
     expect(engine).to.have.property('stop')
   })
 
   describe('constructor', () => {
-    it('begins in status "READY"', () => {
+    it('initializes with the default state', () => {
       expect(engine.status).to.equal('READY')
+      expect(engine.rules.length).to.equal(0)
+      expect(engine.operators.size).to.equal(10)
     })
 
     it('can be initialized with rules', () => {
@@ -70,6 +74,26 @@ describe('Engine', () => {
     })
   })
 
+  describe('addOperator()', () => {
+    it('adds the operator', () => {
+      expect(engine.operators.size).to.equal(10)
+      engine.addOperator('startsWithLetter', (factValue, jsonValue) => {
+        return factValue[0] === jsonValue
+      })
+      expect(engine.operators.size).to.equal(11)
+      expect(engine.operators.get('startsWithLetter')).to.exist
+      expect(engine.operators.get('startsWithLetter')).to.be.an.instanceof(Operator)
+    })
+
+    it('accepts an operator instance', () => {
+      expect(engine.operators.size).to.equal(10)
+      let op = new Operator('my-operator', _ => true)
+      engine.addOperator(op)
+      expect(engine.operators.size).to.equal(11)
+      expect(engine.operators.get('my-operator')).to.equal(op)
+    })
+  })
+
   describe('addFact()', () => {
     const FACT_NAME = 'FACT_NAME'
     const FACT_VALUE = 'FACT_VALUE'
diff --git a/test/operator.test.js b/test/operator.test.js
new file mode 100644
index 0000000..c748b2a
--- /dev/null
+++ b/test/operator.test.js
@@ -0,0 +1,31 @@
+'use strict'
+
+import { Operator } from '../src/index'
+
+describe('Operator', () => {
+  describe('constructor()', () => {
+    function subject (...args) {
+      return new Operator(...args)
+    }
+
+    it('adds the operator', () => {
+      let 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/)
+    })
+  })
+})