From 62fb107be5ac2f1a3fadfd04a73067157023252b Mon Sep 17 00:00:00 2001 From: Ivan Kopeykin Date: Sat, 28 Sep 2019 19:04:22 +0200 Subject: [PATCH] feat: "smart" numbers range --- src/ValidationError.js | 17 +++- src/util/Range.js | 111 ++++++++++++++++++++++++++ test/__snapshots__/index.test.js.snap | 8 +- test/range.test.js | 109 +++++++++++++++++++++++++ 4 files changed, 237 insertions(+), 8 deletions(-) create mode 100644 src/util/Range.js create mode 100644 test/range.test.js diff --git a/src/ValidationError.js b/src/ValidationError.js index d83137f..774387a 100644 --- a/src/ValidationError.js +++ b/src/ValidationError.js @@ -1,3 +1,5 @@ +const Range = require('./util/Range'); + const SPECIFICITY = { type: 1, not: 1, @@ -348,21 +350,28 @@ class ValidationError extends Error { if (likeNumber(schema) || likeInteger(schema)) { const hints = []; + const range = new Range(); if (typeof schema.minimum === 'number') { - hints.push(`should be >= ${schema.minimum}`); + range.left(schema.minimum); } if (typeof schema.exclusiveMinimum === 'number') { - hints.push(`should be > ${schema.exclusiveMinimum}`); + range.left(schema.exclusiveMinimum, true); } if (typeof schema.maximum === 'number') { - hints.push(`should be <= ${schema.maximum}`); + range.right(schema.maximum); } if (typeof schema.exclusiveMaximum === 'number') { - hints.push(`should be > ${schema.exclusiveMaximum}`); + range.right(schema.exclusiveMaximum, true); + } + + const rangeFormat = range.format(); + + if (rangeFormat) { + hints.push(rangeFormat); } if (typeof schema.multipleOf === 'number') { diff --git a/src/util/Range.js b/src/util/Range.js new file mode 100644 index 0000000..ff0e498 --- /dev/null +++ b/src/util/Range.js @@ -0,0 +1,111 @@ +const left = Symbol('left'); +const right = Symbol('right'); + +class Range { + static getOperator(side, exclusive) { + if (side === 'left') { + return exclusive ? '>' : '>='; + } + + return exclusive ? '<' : '<='; + } + + static formatRight(value, logic, exclusive) { + if (logic === false) { + return Range.formatLeft(value, !logic, !exclusive); + } + + return `should be ${Range.getOperator('right', exclusive)} ${value}`; + } + + static formatLeft(value, logic, exclusive) { + if (logic === false) { + return Range.formatRight(value, !logic, !exclusive); + } + + return `should be ${Range.getOperator('left', exclusive)} ${value}`; + } + + static formatRange(start, end, startExclusive, endExclusive, logic) { + let result = 'should be'; + + result += ` ${Range.getOperator( + logic ? 'left' : 'right', + logic ? startExclusive : !startExclusive + )} ${start} `; + result += logic ? 'and' : 'or'; + result += ` ${Range.getOperator( + logic ? 'right' : 'left', + logic ? endExclusive : !endExclusive + )} ${end}`; + + return result; + } + + static getRangeValue(values, logic) { + let minMax = logic ? Infinity : -Infinity; + let j = -1; + const predicate = logic + ? ([value]) => value <= minMax + : ([value]) => value >= minMax; + + for (let i = 0; i < values.length; i++) { + if (predicate(values[i])) { + minMax = values[i][0]; + j = i; + } + } + + if (j > -1) { + return values[j]; + } + + return [Infinity, true]; + } + + constructor() { + this[left] = []; + this[right] = []; + } + + left(value, exclusive = false) { + this[left].push([value, exclusive]); + } + + right(value, exclusive = false) { + this[right].push([value, exclusive]); + } + + format(logic = true) { + const [start, leftExclusive] = Range.getRangeValue(this[left], logic); + const [end, rightExclusive] = Range.getRangeValue(this[right], !logic); + + if (!Number.isFinite(start) && !Number.isFinite(end)) { + return ''; + } + + if (leftExclusive === rightExclusive) { + // e.g. 5 <= x <= 5 + if (leftExclusive === false && start === end) { + return `should be ${logic ? '' : '!'}= ${start}`; + } + + // e.g. 4 < x < 6 + if (leftExclusive === true && start + 1 === end - 1) { + return `should be ${logic ? '' : '!'}= ${start + 1}`; + } + } + + if (Number.isFinite(start) && !Number.isFinite(end)) { + return Range.formatLeft(start, logic, leftExclusive); + } + + if (!Number.isFinite(start) && Number.isFinite(end)) { + return Range.formatRight(end, logic, rightExclusive); + } + + return Range.formatRange(start, end, leftExclusive, rightExclusive, logic); + } +} + +module.exports = Range; diff --git a/test/__snapshots__/index.test.js.snap b/test/__snapshots__/index.test.js.snap index d714692..e377874 100644 --- a/test/__snapshots__/index.test.js.snap +++ b/test/__snapshots__/index.test.js.snap @@ -749,7 +749,7 @@ exports[`Validation should fail validation for integer type 1`] = ` exports[`Validation should fail validation for integer with exclusive maximum 1`] = ` "Invalid configuration object. Object has been initialised using a configuration object that does not match the API schema. - - configuration.integerWithExclusiveMaximum should be a integer (should be > 0)." + - configuration.integerWithExclusiveMaximum should be a integer (should be < 0)." `; exports[`Validation should fail validation for integer with exclusive maximum 2`] = ` @@ -769,7 +769,7 @@ exports[`Validation should fail validation for integer with exclusive minimum 2` exports[`Validation should fail validation for integer with minimum 1`] = ` "Invalid configuration object. Object has been initialised using a configuration object that does not match the API schema. - - configuration.integerWithMinimum should be a integer (should be >= 5, should be <= 20)." + - configuration.integerWithMinimum should be a integer (should be >= 5 and <= 20)." `; exports[`Validation should fail validation for integer with minimum and maximum 1`] = ` @@ -1050,7 +1050,7 @@ exports[`Validation should fail validation for multipleOf 1`] = ` exports[`Validation should fail validation for multipleOf with minimum and maximum 1`] = ` "Invalid configuration object. Object has been initialised using a configuration object that does not match the API schema. - - configuration.multipleOfProp should be a number (should be >= 5, should be <= 20, should be multiple of 5)." + - configuration.multipleOfProp should be a number (should be >= 5 and <= 20, should be multiple of 5)." `; exports[`Validation should fail validation for multipleOf with type number 1`] = ` @@ -1299,7 +1299,7 @@ exports[`Validation should fail validation for number type 1`] = ` exports[`Validation should fail validation for number with minimum and maximum 1`] = ` "Invalid configuration object. Object has been initialised using a configuration object that does not match the API schema. - - configuration.numberWithMinimum should be a number (should be >= 5, should be <= 20)." + - configuration.numberWithMinimum should be a number (should be >= 5 and <= 20)." `; exports[`Validation should fail validation for object #2 1`] = ` diff --git a/test/range.test.js b/test/range.test.js new file mode 100644 index 0000000..6c509fc --- /dev/null +++ b/test/range.test.js @@ -0,0 +1,109 @@ +import Range from '../src/util/Range'; + +it('5 <= x <= 5', () => { + const range = new Range(); + range.left(5); + range.right(5); + + expect(range.format()).toEqual('should be = 5'); +}); + +it('not 5 <= x <= 5', () => { + const range = new Range(); + range.left(5); + range.right(5); + + expect(range.format(false)).toEqual('should be != 5'); +}); + +it('-1 < x < 1', () => { + const range = new Range(); + range.left(-1, true); + range.right(1, true); + + expect(range.format()).toEqual('should be = 0'); +}); + +it('not -1 < x < 1', () => { + const range = new Range(); + range.left(-1, true); + range.right(1, true); + + expect(range.format(false)).toEqual('should be != 0'); +}); + +it('not 0 < x <= 10', () => { + const range = new Range(); + range.left(0, true); + range.right(10, false); + + expect(range.format(false)).toEqual('should be <= 0 or > 10'); +}); + +it('x > 1000', () => { + const range = new Range(); + range.left(10000, false); + range.left(1000, true); + + expect(range.format(true)).toEqual('should be > 1000'); +}); + +it('x < 0', () => { + const range = new Range(); + range.right(-1000, true); + range.right(-0, true); + + expect(range.format()).toEqual('should be < 0'); +}); + +it('x >= -1000', () => { + const range = new Range(); + range.right(-1000, true); + range.right(0, false); + + // expect x >= -1000 since it covers bigger range. [-1000, Infinity] is greater than [0, Infinity] + expect(range.format(false)).toEqual('should be >= -1000'); +}); + +it('x <= 0', () => { + const range = new Range(); + range.left(0, true); + range.left(-100, false); + + // expect x <= 0 since it covers bigger range. [-Infinity, 0] is greater than [-Infinity, -100] + expect(range.format(false)).toEqual('should be <= 0'); +}); + +it('Empty string for infinity range', () => { + const range = new Range(); + + expect(range.format(false)).toEqual(''); +}); + +it('0 < x < 122', () => { + const range = new Range(); + range.left(0, true); + range.right(12, false); + range.right(122, true); + + expect(range.format()).toEqual('should be > 0 and < 122'); +}); + +it('-1 <= x < 10', () => { + const range = new Range(); + range.left(-1, false); + range.left(10, true); + range.right(10, true); + + expect(range.format()).toEqual('should be >= -1 and < 10'); +}); + +it('not 10 < x < 10', () => { + const range = new Range(); + range.left(-1, false); + range.left(10, true); + range.right(10, true); + + // expect x <= 10 since it covers bigger range. [-Infinity, 10] is greater than [-Infinity, -1] + expect(range.format(false)).toEqual('should be <= 10 or >= 10'); +});