diff --git a/src/expression/node/OperatorNode.js b/src/expression/node/OperatorNode.js index 6890db6ece..b681d48316 100644 --- a/src/expression/node/OperatorNode.js +++ b/src/expression/node/OperatorNode.js @@ -249,9 +249,8 @@ export const createOperatorNode = /* #__PURE__ */ factory(name, dependencies, ({ * @param {string} fn Function name, for example 'add' * @param {Node[]} args Operator arguments * @param {boolean} [implicit] Is this an implicit multiplication? - * @param {boolean} [isPercentage] Is this an percentage Operation? */ - constructor (op, fn, args, implicit, isPercentage) { + constructor (op, fn, args, implicit) { super() // validate input if (typeof op !== 'string') { @@ -266,7 +265,6 @@ export const createOperatorNode = /* #__PURE__ */ factory(name, dependencies, ({ } this.implicit = (implicit === true) - this.isPercentage = (isPercentage === true) this.op = op this.fn = fn this.args = args || [] @@ -355,8 +353,7 @@ export const createOperatorNode = /* #__PURE__ */ factory(name, dependencies, ({ for (let i = 0; i < this.args.length; i++) { args[i] = this._ifNode(callback(this.args[i], 'args[' + i + ']', this)) } - return new OperatorNode( - this.op, this.fn, args, this.implicit, this.isPercentage) + return new OperatorNode(this.op, this.fn, args, this.implicit) } /** @@ -365,7 +362,7 @@ export const createOperatorNode = /* #__PURE__ */ factory(name, dependencies, ({ */ clone () { return new OperatorNode( - this.op, this.fn, this.args.slice(0), this.implicit, this.isPercentage) + this.op, this.fn, this.args.slice(0), this.implicit) } /** @@ -472,8 +469,7 @@ export const createOperatorNode = /* #__PURE__ */ factory(name, dependencies, ({ op: this.op, fn: this.fn, args: this.args, - implicit: this.implicit, - isPercentage: this.isPercentage + implicit: this.implicit } } @@ -483,16 +479,15 @@ export const createOperatorNode = /* #__PURE__ */ factory(name, dependencies, ({ * An object structured like * ``` * {"mathjs": "OperatorNode", - * "op": "+", "fn": "add", "args": [...], - * "implicit": false, - * "isPercentage":false} + * "op": "+", "fn": "add", + * "args": [...], + * "implicit": false} * ``` * where mathjs is optional * @returns {OperatorNode} */ static fromJSON (json) { - return new OperatorNode( - json.op, json.fn, json.args, json.implicit, json.isPercentage) + return new OperatorNode(json.op, json.fn, json.args, json.implicit) } /** diff --git a/src/expression/parse.js b/src/expression/parse.js index a953835bb4..449a42f829 100644 --- a/src/expression/parse.js +++ b/src/expression/parse.js @@ -177,6 +177,11 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({ not: true } + const UNIT_DELIMITERS = { + in: true, + '%': true + } + const CONSTANTS = { true: true, false: false, @@ -941,14 +946,9 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({ getTokenSkipNewline(state) - if (name === 'in' && '])},;'.includes(state.token)) { - // end of expression -> this is the unit 'in' ('inch') - node = new OperatorNode('*', 'multiply', [node, new SymbolNode('in')], true) - } else { - // operator 'a to b' or 'a in b' - params = [node, parseRange(state)] - node = new OperatorNode(name, fn, params) - } + // operator 'a to b' or 'a in b' + params = [node, parseRange(state)] + node = new OperatorNode(name, fn, params) } return node @@ -1006,25 +1006,18 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({ * @private */ function parseAddSubtract (state) { - let node, name, fn, params - - node = parseMultiplyDivideModulus(state) + let node = parseMultiplyDivideModulus(state) const operators = { '+': 'add', '-': 'subtract' } while (hasOwnProperty(operators, state.token)) { - name = state.token - fn = operators[name] + const name = state.token + const fn = operators[name] getTokenSkipNewline(state) - const rightNode = parseMultiplyDivideModulus(state) - if (rightNode.isPercentage) { - params = [node, new OperatorNode('*', 'multiply', [node, rightNode])] - } else { - params = [node, rightNode] - } + const params = [node, parseMultiplyDivideModulus(state)] node = new OperatorNode(name, fn, params) } @@ -1079,9 +1072,71 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({ last = node while (true) { - if ((state.tokenType === TOKENTYPE.SYMBOL) || - (state.token === 'in' && isConstantNode(node)) || - (state.token === 'in' && isOperatorNode(node) && node.fn === 'unaryMinus' && isConstantNode(node.args[0])) || + // The idiosyncrasies of `%` in the mathjs language are handled + // here (and only here) because percent is treated as a dimensionless + // unit. Hence, we first encounter the possibility of the character `%` + // when we attempt an implicit multiplication. At this point, if we + // happen to be looking at `%`, we decide once and for all if it is a + // unit symbol or the 'mod' operator. + // Note this approach handles disambiguation of `in` as a unit or + // conversion operator in this same place, although unfortunately + // with somewhat different special cases. + + // First determine if a unit delimiter is being used as a unit or + // a delimiter (in the former case it is implicit multiplication, + // in latter case not). + let delimiterAsUnit = state.token in UNIT_DELIMITERS + if (delimiterAsUnit) { + // Here we check if we are in a pattern in which this token should + // _not_ in fact be interpreted as a unit. + + // First try looking ahead one token for general cases to disambiguate + const saveState = Object.assign({}, state) + getTokenSkipNewline(state) + if (state.token === '(' || + state.tokenType === TOKENTYPE.NUMBER || + state.tokenType === TOKENTYPE.SYMBOL || + // Now check special cases for `in`: + // Parsing `5 in in`, the first `in` is a unit, second is operator + (saveState.token === 'in' && + !(isConstantNode(node) || + (isOperatorNode(node) && node.fn === 'unaryMinus')) && + state.token in UNIT_DELIMITERS) + ) { + delimiterAsUnit = false + } else if (saveState.token === '%') { // Now check special cases for % + // Prevent doubled percent + if (isOperatorNode(node) && + (node.fn === 'mod' || + (isSymbolNode(node.args[1]) && node.args[1].name === '%')) + ) { + delimiterAsUnit = false + // So now % is an operator. If the next token is a + // UNIT_DELIMITER, that is a syntax error: + if (state.token in UNIT_DELIMITERS) { + throw createSyntaxError( + state, `Unexpected token '${state.token}' after '%'.`) + } + } else if (state.token !== '%') { + // only treat the '%' of '3 % +[EXPR]' or '3% - [EXPR]' as percent + // if '+[EXPR]' or '- [EXPR]' is a percentage. + // Otherwise, this % is the modulo operator, e.g. + // 3 % +100 == 3 and 3 % -100 == -97 + try { + const rhs = parseImplicitMultiplication(state) + if (!(isOperatorNode(rhs) && rhs.implicit && + rhs.args[1].name in { percent: true, '%': true }) + ) { + delimiterAsUnit = false + } + } catch {} + } + } + Object.assign(state, saveState) + } + + // Now we can go ahead and check for implicit multiplication: + if ((state.tokenType === TOKENTYPE.SYMBOL) || delimiterAsUnit || (state.tokenType === TOKENTYPE.NUMBER && !isConstantNode(last) && (!isOperatorNode(last) || last.op === '!')) || @@ -1090,6 +1145,7 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({ // // symbol: implicit multiplication like '2a', '(2+3)a', 'a b' // number: implicit multiplication like '(2+3)2' + // units: implicit multiplication like '5 m', '5 in', or '5%' // parenthesis: implicit multiplication like '2(3+4)', '(3+4)(1+2)' last = parseRule2(state) node = new OperatorNode('*', 'multiply', [node, last], true /* implicit */) @@ -1111,7 +1167,7 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({ * @private */ function parseRule2 (state) { - let node = parseUnaryPercentage(state) + let node = parseUnary(state) let last = node const tokenStates = [] @@ -1134,7 +1190,7 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({ // Rewind once and build the "number / number" node; the symbol will be consumed later Object.assign(state, tokenStates.pop()) tokenStates.pop() - last = parseUnaryPercentage(state) + last = parseUnary(state) node = new OperatorNode('/', 'divide', [node, last]) } else { // Not a match, so rewind @@ -1155,36 +1211,6 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({ return node } - /** - * Unary percentage operator (treated as `value / 100`) - * @return {Node} node - * @private - */ - function parseUnaryPercentage (state) { - let node = parseUnary(state) - - if (state.token === '%') { - const previousState = Object.assign({}, state) - getTokenSkipNewline(state) - // We need to decide if this is a unary percentage % or binary modulo % - // So we attempt to parse a unary expression at this point. - // If it fails, then the only possibility is that this is a unary percentage. - // If it succeeds, then we presume that this must be binary modulo, since the - // only things that parseUnary can handle are _higher_ precedence than unary %. - try { - parseUnary(state) - // Not sure if we could somehow use the result of that parseUnary? Without - // further analysis/testing, safer just to discard and let the parse proceed - Object.assign(state, previousState) - } catch { - // Not seeing a term at this point, so was a unary % - node = new OperatorNode('/', 'divide', [node, new ConstantNode(100)], false, true) - } - } - - return node - } - /** * Unary plus and minus, and logical and bitwise not * @return {Node} node @@ -1357,7 +1383,9 @@ export const createParse = /* #__PURE__ */ factory(name, dependencies, ({ let node, name if (state.tokenType === TOKENTYPE.SYMBOL || - (state.tokenType === TOKENTYPE.DELIMITER && state.token in NAMED_DELIMITERS)) { + (state.tokenType === TOKENTYPE.DELIMITER && + (state.token in UNIT_DELIMITERS || state.token in NAMED_DELIMITERS)) + ) { name = state.token getToken(state) diff --git a/src/function/arithmetic/add.js b/src/function/arithmetic/add.js index b9bc0ae9af..769f62ab43 100644 --- a/src/function/arithmetic/add.js +++ b/src/function/arithmetic/add.js @@ -10,73 +10,89 @@ const dependencies = [ 'matrix', 'addScalar', 'equalScalar', + 'Unit', 'DenseMatrix', 'SparseMatrix', - 'concat' + 'multiply' ] -export const createAdd = /* #__PURE__ */ factory( - name, - dependencies, - ({ typed, matrix, addScalar, equalScalar, DenseMatrix, SparseMatrix, concat }) => { - const matAlgo01xDSid = createMatAlgo01xDSid({ typed }) - const matAlgo04xSidSid = createMatAlgo04xSidSid({ typed, equalScalar }) - const matAlgo10xSids = createMatAlgo10xSids({ typed, DenseMatrix }) - const matrixAlgorithmSuite = createMatrixAlgorithmSuite({ typed, matrix, concat }) - /** - * Add two or more values, `x + y`. - * For matrices, the function is evaluated element wise. - * - * Syntax: - * - * math.add(x, y) - * math.add(x, y, z, ...) - * - * Examples: - * - * math.add(2, 3) // returns number 5 - * math.add(2, 3, 4) // returns number 9 - * - * const a = math.complex(2, 3) - * const b = math.complex(-4, 1) - * math.add(a, b) // returns Complex -2 + 4i - * - * math.add([1, 2, 3], 4) // returns Array [5, 6, 7] - * - * const c = math.unit('5 cm') - * const d = math.unit('2.1 mm') - * math.add(c, d) // returns Unit 52.1 mm - * - * math.add("2.3", "4") // returns number 6.3 - * - * See also: - * - * subtract, sum - * - * @param {number | BigNumber | bigint | Fraction | Complex | Unit | Array | Matrix} x First value to add - * @param {number | BigNumber | bigint | Fraction | Complex | Unit | Array | Matrix} y Second value to add - * @return {number | BigNumber | bigint | Fraction | Complex | Unit | Array | Matrix} Sum of `x` and `y` - */ - return typed( - name, - { - 'any, any': addScalar, +export const createAdd = /* #__PURE__ */ factory(name, dependencies, ({ + typed, matrix, addScalar, equalScalar, Unit, DenseMatrix, SparseMatrix, + multiply +}) => { + const matAlgo01xDSid = createMatAlgo01xDSid({ typed }) + const matAlgo04xSidSid = createMatAlgo04xSidSid({ typed, equalScalar }) + const matAlgo10xSids = createMatAlgo10xSids({ typed, DenseMatrix }) + const matrixAlgorithmSuite = createMatrixAlgorithmSuite({ typed, matrix }) + /** + * Add two or more values, `x + y`. + * For matrices, the function is evaluated element wise. + * + * Syntax: + * + * math.add(x, y) + * math.add(x, y, z, ...) + * + * Examples: + * + * math.add(2, 3) // returns number 5 + * math.add(2, 3, 4) // returns number 9 + * + * const a = math.complex(2, 3) + * const b = math.complex(-4, 1) + * math.add(a, b) // returns Complex -2 + 4i + * + * math.add([1, 2, 3], 4) // returns Array [5, 6, 7] + * + * const c = math.unit('5 cm') + * const d = math.unit('2.1 mm') + * math.add(c, d) // returns Unit 52.1 mm + * + * math.add("2.3", "4") // returns number 6.3 + * + * See also: + * + * subtract, sum + * + * @param {number | BigNumber | bigint | Fraction | Complex | Unit | Array | Matrix} x First value to add + * @param {number | BigNumber | bigint | Fraction | Complex | Unit | Array | Matrix} y Second value to add + * @return {number | BigNumber | bigint | Fraction | Complex | Unit | Array | Matrix} Sum of `x` and `y` + */ + return typed( + name, + { + 'any, Unit': typed.referToSelf(self => (s, u) => { + if (!u.equalBase(Unit.BASE_UNITS.NONE) || + !(u.units[0].unit.name in { percent: true, '%': true })) { + throw new TypeError('Cannot add non-unit and dimensioned value') + } + return self(s, multiply(s, u.value)) + }), - 'any, any, ...any': typed.referToSelf(self => (x, y, rest) => { - let result = self(x, y) + 'Unit, any': typed.referToSelf(self => (u, s) => { + if (!u.equalBase(Unit.BASE_UNITS.NONE)) { + throw new TypeError('Cannot add dimensioned and non-unit value') + } + return self(u.value, s) + }), - for (let i = 0; i < rest.length; i++) { - result = self(result, rest[i]) - } + 'any, any': addScalar, - return result - }) - }, - matrixAlgorithmSuite({ - elop: addScalar, - DS: matAlgo01xDSid, - SS: matAlgo04xSidSid, - Ss: matAlgo10xSids + 'any, any, ...any': typed.referToSelf(self => (x, y, rest) => { + let result = self(x, y) + + for (let i = 0; i < rest.length; i++) { + result = self(result, rest[i]) + } + + return result }) - ) - }) + }, + matrixAlgorithmSuite({ + elop: addScalar, + DS: matAlgo01xDSid, + SS: matAlgo04xSidSid, + Ss: matAlgo10xSids + }) + ) +}) diff --git a/src/function/arithmetic/addScalar.js b/src/function/arithmetic/addScalar.js index b846d1c8f1..4cf91796ca 100644 --- a/src/function/arithmetic/addScalar.js +++ b/src/function/arithmetic/addScalar.js @@ -2,9 +2,11 @@ import { factory } from '../../utils/factory.js' import { addNumber } from '../../plain/number/index.js' const name = 'addScalar' -const dependencies = ['typed'] +const dependencies = ['typed', 'multiplyScalar'] -export const createAddScalar = /* #__PURE__ */ factory(name, dependencies, ({ typed }) => { +export const createAddScalar = /* #__PURE__ */ factory(name, dependencies, ({ + typed, multiplyScalar +}) => { /** * Add two scalar values, `x + y`. * This function is meant for internal use: it is used by the public function @@ -44,7 +46,16 @@ export const createAddScalar = /* #__PURE__ */ factory(name, dependencies, ({ ty if (y.value === null || y.value === undefined) { throw new Error('Parameter y contains a unit with undefined value') } - if (!x.equalBase(y)) throw new Error('Units do not match') + if (!x.equalBase(y)) { + if (y.dimensions.every(dim => dim === 0) && + y.units[0].unit.name in { percent: true, '%': true } + ) { + const res = x.clone() + res.value = self(res.value, multiplyScalar(res.value, y.value)) + return res + } + throw new Error('Units do not match') + } const res = x.clone() res.value = diff --git a/src/function/arithmetic/subtract.js b/src/function/arithmetic/subtract.js index aa6732484b..26e2c36c80 100644 --- a/src/function/arithmetic/subtract.js +++ b/src/function/arithmetic/subtract.js @@ -13,19 +13,21 @@ const dependencies = [ 'equalScalar', 'subtractScalar', 'unaryMinus', + 'Unit', 'DenseMatrix', - 'concat' + 'multiply' ] -export const createSubtract = /* #__PURE__ */ factory(name, dependencies, ({ typed, matrix, equalScalar, subtractScalar, unaryMinus, DenseMatrix, concat }) => { - // TODO: split function subtract in two: subtract and subtractScalar - +export const createSubtract = /* #__PURE__ */ factory(name, dependencies, ({ + typed, matrix, equalScalar, subtractScalar, unaryMinus, Unit, DenseMatrix, + multiply +}) => { const matAlgo01xDSid = createMatAlgo01xDSid({ typed }) const matAlgo03xDSf = createMatAlgo03xDSf({ typed }) const matAlgo05xSfSf = createMatAlgo05xSfSf({ typed, equalScalar }) const matAlgo10xSids = createMatAlgo10xSids({ typed, DenseMatrix }) const matAlgo12xSfs = createMatAlgo12xSfs({ typed, DenseMatrix }) - const matrixAlgorithmSuite = createMatrixAlgorithmSuite({ typed, matrix, concat }) + const matrixAlgorithmSuite = createMatrixAlgorithmSuite({ typed, matrix }) /** * Subtract two values, `x - y`. @@ -60,6 +62,21 @@ export const createSubtract = /* #__PURE__ */ factory(name, dependencies, ({ typ return typed( name, { + 'any, Unit': typed.referToSelf(self => (s, u) => { + if (!u.equalBase(Unit.BASE_UNITS.NONE) || + !(u.units[0].unit.name in { percent: true, '%': true })) { + throw new TypeError('Cannot subtract non-unit and dimensioned value') + } + return self(s, multiply(s, u.value)) + }), + + 'Unit, any': typed.referToSelf(self => (u, s) => { + if (!u.equalBase(Unit.BASE_UNITS.NONE)) { + throw new TypeError('Cannot subtract dimensioned and non-unit value') + } + return self(u.value, s) + }), + 'any, any': subtractScalar }, matrixAlgorithmSuite({ diff --git a/src/function/arithmetic/subtractScalar.js b/src/function/arithmetic/subtractScalar.js index 075f5300b9..e0a6c5f764 100644 --- a/src/function/arithmetic/subtractScalar.js +++ b/src/function/arithmetic/subtractScalar.js @@ -2,9 +2,9 @@ import { factory } from '../../utils/factory.js' import { subtractNumber } from '../../plain/number/index.js' const name = 'subtractScalar' -const dependencies = ['typed'] +const dependencies = ['typed', 'multiplyScalar'] -export const createSubtractScalar = /* #__PURE__ */ factory(name, dependencies, ({ typed }) => { +export const createSubtractScalar = /* #__PURE__ */ factory(name, dependencies, ({ typed, multiplyScalar }) => { /** * Subtract two scalar values, `x - y`. * This function is meant for internal use: it is used by the public function @@ -44,7 +44,16 @@ export const createSubtractScalar = /* #__PURE__ */ factory(name, dependencies, if (y.value === null || y.value === undefined) { throw new Error('Parameter y contains a unit with undefined value') } - if (!x.equalBase(y)) throw new Error('Units do not match') + if (!x.equalBase(y)) { + if (y.dimensions.every(dim => dim === 0) && + y.units[0].unit.name in { percent: true, '%': true } + ) { + const res = x.clone() + res.value = self(res.value, multiplyScalar(res.value, y.value)) + return res + } + throw new Error('Units do not match') + } const res = x.clone() res.value = diff --git a/src/type/number.js b/src/type/number.js index 41b0864c33..31439a0c3e 100644 --- a/src/type/number.js +++ b/src/type/number.js @@ -40,7 +40,9 @@ function makeNumberFromNonDecimalParts (parts) { return result } -export const createNumber = /* #__PURE__ */ factory(name, dependencies, ({ typed }) => { +export const createNumber = /* #__PURE__ */ factory(name, dependencies, ({ + typed, Unit +}) => { /** * Create a number or convert a string, boolean, or unit to a number. * When value is a matrix, all elements will be converted to number. @@ -121,6 +123,7 @@ export const createNumber = /* #__PURE__ */ factory(name, dependencies, ({ typed }, Unit: typed.referToSelf(self => (x) => { + if (x.equalBase(x.constructor.BASE_UNITS.NONE)) return self(x.value) const clone = x.clone() clone.value = self(x.value) return clone diff --git a/src/type/unit/Unit.js b/src/type/unit/Unit.js index daebd92a87..9672f14915 100644 --- a/src/type/unit/Unit.js +++ b/src/type/unit/Unit.js @@ -217,15 +217,15 @@ export const createUnitClass = /* #__PURE__ */ factory(name, dependencies, ({ function parseUnit () { let unitName = '' - // Alphanumeric characters only; matches [a-zA-Z0-9] - while (isDigit(c) || Unit.isValidAlpha(c)) { + // Alphanumeric characters and percent only; matches [a-zA-Z0-9%] + while (isDigit(c) || c === '%' || Unit.isValidAlpha(c)) { unitName += c next() } - // Must begin with [a-zA-Z] + // Must begin with [a-zA-Z%] const firstC = unitName.charAt(0) - if (Unit.isValidAlpha(firstC)) { + if (Unit.isValidAlpha(firstC) || firstC === '%') { return unitName } else { return null @@ -671,6 +671,8 @@ export const createUnitClass = /* #__PURE__ */ factory(name, dependencies, ({ if (isUnit(_other)) { res.skipAutomaticSimplification = false } + // If either operand is valueless, preserve unit + if (this.value === null || other.value === null) return res return getNumericIfUnitless(res) } @@ -1647,6 +1649,14 @@ export const createUnitClass = /* #__PURE__ */ factory(name, dependencies, ({ const UNIT_NONE = { name: '', base: BASE_UNIT_NONE, value: 1, offset: 0, dimensions: BASE_DIMENSIONS.map(x => 0) } const UNITS = { + // unitless + percent: { + name: 'percent', + base: BASE_UNITS.NONE, + prefixes: PREFIXES.NONE, + value: 0.01, + offset: 0 + }, // length meter: { name: 'meter', @@ -2803,6 +2813,7 @@ export const createUnitClass = /* #__PURE__ */ factory(name, dependencies, ({ // aliases (formerly plurals) // note that ALIASES is only used at creation to create more entries in UNITS by copying the aliased units const ALIASES = { + '%': 'percent', meters: 'meter', inches: 'inch', feet: 'foot', diff --git a/test/unit-tests/expression/node/OperatorNode.test.js b/test/unit-tests/expression/node/OperatorNode.test.js index 812c12bd11..0fe9d04252 100644 --- a/test/unit-tests/expression/node/OperatorNode.test.js +++ b/test/unit-tests/expression/node/OperatorNode.test.js @@ -590,8 +590,7 @@ describe('OperatorNode', function () { op: '+', fn: 'add', args: [one, two], - implicit: true, - isPercentage: false + implicit: true }) const parsed = OperatorNode.fromJSON(json) diff --git a/test/unit-tests/expression/parse.test.js b/test/unit-tests/expression/parse.test.js index 1b799bf2d0..4f0d3890f2 100644 --- a/test/unit-tests/expression/parse.test.js +++ b/test/unit-tests/expression/parse.test.js @@ -616,6 +616,9 @@ describe('parse', function () { it('should evaluate unit "in" (should not conflict with operator "in")', function () { approxDeepEqual(parseAndEval('2 in'), new Unit(2, 'in')) + const funnyUnit = new Unit(6, 'kg in^2') + funnyUnit.skipAutomaticSimplification = false + assert.deepStrictEqual(parseAndEval('6 kg in^2'), funnyUnit) approxEqual(parseAndEval('(2 lbf in).toNumeric("lbf in")'), 2) approxEqual(parseAndEval('[2 lbf in][1].toNumeric("lbf in")'), 2) approxEqual(parseAndEval('[2 lbf in, 5][1].toNumeric("lbf in")'), 2) @@ -1615,6 +1618,86 @@ describe('parse', function () { assert.strictEqual(parseAndEval('2 < 4 > 3 <= 5 >= 5'), true) }) + it('should allow % or percent', function () { + assert.strictEqual( + parseAndEval('60 + 17%'), parseAndEval('60 + 17 percent')) + }) + + it('should add and subtract percentages intuitively', function () { + approxDeepEqual(parseAndEval('10% + 20%'), new Unit(30, '%')) + approxDeepEqual(parseAndEval('10% - 20%'), new Unit(-10, '%')) + approxDeepEqual(parseAndEval('10% + 20% + 30%'), new Unit(60, '%')) + approxDeepEqual(parseAndEval('10% + 50% - 20%'), new Unit(40, '%')) + }) + + it('should preserve relative percentage on numbers', function () { + approxEqual(parseAndEval('50 + 20% + 10%'), 66) + }) + + it('should compound percentages on variables', function () { + const scope = { x: 10 } + approxEqual(parseAndEval('x + 20% + 10%', scope), 13.2) + approxEqual(parseAndEval('x - 10% - 20%', scope), 7.2) + }) + + it('should keep percent sums before adding a variable', function () { + const scope = { x: 1 } + // NOTE: The following parses as '10 % (+20) % (+x)', as intended + approxEqual(parseAndEval('10% + 20% + x', scope), 0) + approxEqual(parseAndEval('(10%) + (20%) + x', scope), 1.3) + approxEqual(parseAndEval('10 percent + 20percent + x', scope), 1.3) + approxEqual(parseAndEval('10% - 20% - x', scope), 0) + approxEqual(parseAndEval('(10%) - (20%) - x', scope), -1.1) + }) + + it('should support parentheses with percentages', function () { + const thirtypc = new Unit(30, '%') + const sixtypc = new Unit(60, '%') + approxDeepEqual(parseAndEval('(10%) + (20%)'), thirtypc) + // The following parses as 10 mod +20%, and mod does not handle units + assert.throws(() => parseAndEval('10% + (20%)'), TypeError) + approxDeepEqual(parseAndEval('(10%) + 20%'), thirtypc) + approxDeepEqual(parseAndEval('(10% + 20%) + 30%'), sixtypc) + approxDeepEqual(parseAndEval('(10%) + (20% + 30%)'), sixtypc) + approxDeepEqual(parseAndEval('10% + (20 + 30)%'), sixtypc) + }) + + it('should add more pure percentages arithmetically', function () { + approxDeepEqual(parseAndEval('50% + 20%'), new Unit(70, '%')) + approxDeepEqual(parseAndEval('10% + 20% - 30%'), new Unit(0, '%')) + approxDeepEqual(parseAndEval('10% + 20% + 30% + 40%'), new Unit(100, '%')) + }) + + it('should combine percentages inside multiplication and with parentheses', function () { + const scope = { x: 10 } + approxEqual(parseAndEval('x * (10% + 20%)', scope), 3) + approxEqual(parseAndEval('(10% + 20%) * x', scope), 3) + }) + + it('should preserve semantics when grouping percentage additions explicitly', function () { + approxEqual(parseAndEval('50 + (20% + 10%)'), 65) + const scope = { x: 100 } + approxEqual(parseAndEval('x + (20% + 10%)', scope), 130) + }) + + it('should support units with percentages on the right-hand side', function () { + approxDeepEqual(parseAndEval('10 cm + 20%'), new Unit(12, 'cm')) + approxDeepEqual(parseAndEval('20 liter - 15%'), new Unit(17, 'liter')) + }) + + it('should consistently choose between mod and percent', function () { + assert.strictEqual(parseAndEval('10% + 20 % 3'), 1) // 10 mod +20 mod 3 + assert.strictEqual( + parseAndEval('(10%) + 20% 3'), 2.1) // 10% plus 20 mod 3 + approxEqual(parseAndEval('(10% + 20%)3'), 0.9) + assert.strictEqual(parseAndEval('20 % 3 + 10 %'), 2.2) + }) + + it('should add percents in expression or JavaScript', function () { + assert.strictEqual( + parseAndEval('30 + 22%'), math.add(30, math.unit(22, 'percent'))) + }) + it('should parse mod %', function () { approxEqual(parseAndEval('8 % 3'), 2) approxEqual(parseAndEval('80% pi'), 1.4601836602551685) @@ -1625,8 +1708,9 @@ describe('parse', function () { }) it('should parse % value', function () { - approxEqual(parseAndEval('8 % '), 0.08) - approxEqual(parseAndEval('100%'), 1) + approxDeepEqual(parseAndEval('8 % '), math.unit(8, '%')) + assert.strictEqual(parseAndEval('number(25%)'), 0.25) + approxDeepEqual(parseAndEval('100%'), math.unit(100, '%')) }) it('should parse % with multiplication', function () { @@ -1660,7 +1744,7 @@ describe('parse', function () { it('should reject repeated unary percentage operators', function () { assert.throws(function () { math.parse('17%%') }, /Unexpected end of expression/) assert.throws(function () { math.parse('17%%*5') }, /Value expected \(char 5\)/) - assert.throws(function () { math.parse('10/200%%%3') }, /Value expected \(char 9\)/) + assert.throws(function () { math.parse('10/200%%%3') }, SyntaxError) }) it('should parse unary % with addition', function () { diff --git a/test/unit-tests/function/arithmetic/addScalar.test.js b/test/unit-tests/function/arithmetic/addScalar.test.js index 1aa8a46be2..0f3939b2c4 100644 --- a/test/unit-tests/function/arithmetic/addScalar.test.js +++ b/test/unit-tests/function/arithmetic/addScalar.test.js @@ -176,9 +176,9 @@ describe('addScalar', function () { }) it('should throw an error in case of a unit and non-unit argument', function () { - assert.throws(function () { add(math.unit('5cm'), 2) }, /TypeError: Unexpected type of argument in function add/) - assert.throws(function () { add(math.unit('5cm'), new Date()) }, /TypeError: Unexpected type of argument in function add/) - assert.throws(function () { add(new Date(), math.unit('5cm')) }, /TypeError: Unexpected type of argument in function add/) + assert.throws(function () { add(math.unit('5cm'), 2) }, TypeError) + assert.throws(function () { add(math.unit('5cm'), new Date()) }, TypeError) + assert.throws(function () { add(new Date(), math.unit('5cm')) }, TypeError) }) it('should throw an error in case of invalid number of arguments', function () { diff --git a/test/unit-tests/function/arithmetic/cube.test.js b/test/unit-tests/function/arithmetic/cube.test.js index a36a6e4fb8..1944c9a544 100644 --- a/test/unit-tests/function/arithmetic/cube.test.js +++ b/test/unit-tests/function/arithmetic/cube.test.js @@ -45,9 +45,14 @@ describe('cube', function () { }) it('should return the cube of a unit', function () { - assert.strictEqual(cube(math.unit('4 cm')).toString(), '64 cm^3') - assert.strictEqual(cube(math.unit('-2 cm')).toString(), '-8 cm^3') - assert.strictEqual(cube(math.unit('0 cm')).toString(), '0 cm^3') + // powers simplify so test equality carefully + const ml64 = new math.Unit(64, 'cm^3') + ml64.skipAutomaticSimplification = false + assert.deepStrictEqual(cube(math.unit('4 cm')), ml64) + const mlneg8 = new math.Unit(-8, 'cm^3') + mlneg8.skipAutomaticSimplification = false + assert.deepStrictEqual(cube(math.unit('-2 cm')), mlneg8) + assert.strictEqual(cube(math.unit('0 cm')).toString(), '0 liter') }) it('should throw an error with strings', function () { diff --git a/test/unit-tests/json/replacer.test.js b/test/unit-tests/json/replacer.test.js index e1695eeca3..d85e4126f8 100644 --- a/test/unit-tests/json/replacer.test.js +++ b/test/unit-tests/json/replacer.test.js @@ -173,14 +173,12 @@ describe('replacer', function () { name: 'x' } ], - implicit: true, - isPercentage: false + implicit: true } ] } ], - implicit: false, - isPercentage: false + implicit: false } assert.deepStrictEqual(JSON.parse(JSON.stringify(node)), json) diff --git a/test/unit-tests/type/unit/function/unit.test.js b/test/unit-tests/type/unit/function/unit.test.js index ad852dd906..0a9f8bf13b 100644 --- a/test/unit-tests/type/unit/function/unit.test.js +++ b/test/unit-tests/type/unit/function/unit.test.js @@ -10,6 +10,7 @@ describe('unit', function () { }) it('should parse a valid string to a unit', function () { + assert.deepStrictEqual(unit('4 cm'), new Unit(4, 'cm')) assert.deepStrictEqual(unit('5 cm').toString(), '5 cm') assert.deepStrictEqual(unit('5000 cm').toString(), '50 m') assert.deepStrictEqual(unit('10 kg').toString(), '10 kg')