From 7f2c5bc09d52e480399c89462bb8048007b3e5dd Mon Sep 17 00:00:00 2001 From: hhow09 Date: Wed, 22 Jan 2025 21:59:02 +0100 Subject: [PATCH] feat: replace math.js with self developed algorithm --- backend/README.md | 7 ++ backend/jest.config.js | 1 + backend/package-lock.json | 102 +---------------- backend/package.json | 2 +- backend/src/command-service.ts | 32 +----- .../evaluate.spec.ts} | 50 ++++---- backend/src/math/evaluate.ts | 70 ++++++++++++ backend/src/math/types.ts | 108 ++++++++++++++++++ 8 files changed, 219 insertions(+), 153 deletions(-) rename backend/src/{command-service.spec.ts => math/evaluate.spec.ts} (51%) create mode 100644 backend/src/math/evaluate.ts create mode 100644 backend/src/math/types.ts diff --git a/backend/README.md b/backend/README.md index 87667aa..d9d8d0a 100644 --- a/backend/README.md +++ b/backend/README.md @@ -15,3 +15,10 @@ npm run start ```bash npm run test ``` + +## Limitations on calculation +- All whitespace is ignored, therefore `1 + 2 3` will consider as `1 + 23` +- negative sign in expression is not supported: e.g. `5 * -3` will return error. +- large number will be converted to exponential notation: e.g. `999999999999 * 999999999999` will return `9.99999999998e+23` + - default threshold of exponent is `20` + - ref: https://mikemcl.github.io/decimal.js/#precision \ No newline at end of file diff --git a/backend/jest.config.js b/backend/jest.config.js index a96cab4..b0b425a 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -8,6 +8,7 @@ module.exports = { transform: { "^.+.tsx?$": ["ts-jest",{}], }, + testMatch: ['**/*.spec.ts'], setupFiles: ["dotenv/config"] }; \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index f448b34..d151668 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,9 +12,9 @@ "@types/body-parser": "^1.19.5", "@types/express": "^5.0.0", "@types/node": "^22.10.7", + "decimal.js": "^10.4.3", "dotenv": "^16.4.7", "express": "^4.21.2", - "mathjs": "^14.0.1", "mongodb": "^6.12.0", "pino": "^9.6.0", "socket.io": "^4.8.1" @@ -477,18 +477,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/runtime": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", - "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", - "license": "MIT", - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/template": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", @@ -2610,19 +2598,6 @@ "dev": true, "license": "MIT" }, - "node_modules/complex.js": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.4.2.tgz", - "integrity": "sha512-qtx7HRhPGSCBtGiST4/WGHuW+zeaND/6Ld+db6PbrulIB1i2Ev/2UPiqcmpQNPSyfBKraC0EOvOKCB5dGZKt3g==", - "license": "MIT", - "engines": { - "node": "*" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/rawify" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3272,12 +3247,6 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, - "node_modules/escape-latex": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/escape-latex/-/escape-latex-1.2.0.tgz", - "integrity": "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==", - "license": "MIT" - }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -3841,19 +3810,6 @@ "node": ">= 0.6" } }, - "node_modules/fraction.js": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.2.1.tgz", - "integrity": "sha512-Ah6t/7YCYjrPUFUFsOsRLMXAdnYM+aQwmojD2Ayb/Ezr82SwES0vuyQ8qZ3QO8n9j7W14VJuVZZet8U3bhSdQQ==", - "license": "MIT", - "engines": { - "node": ">= 12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/rawify" - } - }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -4902,12 +4858,6 @@ "node": ">=10" } }, - "node_modules/javascript-natural-sort": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz", - "integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==", - "license": "MIT" - }, "node_modules/jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", @@ -5774,29 +5724,6 @@ "node": ">= 0.4" } }, - "node_modules/mathjs": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-14.0.1.tgz", - "integrity": "sha512-yyJgLwC6UXuve724np8tHRMYaTtb5UqiOGQkjwbSXgH8y1C/LcJ0pvdNDZLI2LT7r+iExh2Y5HwfAY+oZFtGIQ==", - "license": "Apache-2.0", - "dependencies": { - "@babel/runtime": "^7.25.7", - "complex.js": "^2.2.5", - "decimal.js": "^10.4.3", - "escape-latex": "^1.2.0", - "fraction.js": "^5.2.1", - "javascript-natural-sort": "^0.7.1", - "seedrandom": "^3.0.5", - "tiny-emitter": "^2.1.0", - "typed-function": "^4.2.1" - }, - "bin": { - "mathjs": "bin/cli.js" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -6723,12 +6650,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "license": "MIT" - }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -6946,12 +6867,6 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, - "node_modules/seedrandom": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", - "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==", - "license": "MIT" - }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -7601,12 +7516,6 @@ "real-require": "^0.2.0" } }, - "node_modules/tiny-emitter": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", - "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", - "license": "MIT" - }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -7894,15 +7803,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/typed-function": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.1.tgz", - "integrity": "sha512-EGjWssW7Tsk4DGfE+5yluuljS1OGYWiI1J6e8puZz9nTMM51Oug8CD5Zo4gWMsOhq5BI+1bF+rWTm4Vbj3ivRA==", - "license": "MIT", - "engines": { - "node": ">= 18" - } - }, "node_modules/typescript": { "version": "5.7.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", diff --git a/backend/package.json b/backend/package.json index 2bc4cb3..c858fa3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -15,9 +15,9 @@ "@types/body-parser": "^1.19.5", "@types/express": "^5.0.0", "@types/node": "^22.10.7", + "decimal.js": "^10.4.3", "dotenv": "^16.4.7", "express": "^4.21.2", - "mathjs": "^14.0.1", "mongodb": "^6.12.0", "pino": "^9.6.0", "socket.io": "^4.8.1" diff --git a/backend/src/command-service.ts b/backend/src/command-service.ts index cdf0bde..4de0cda 100644 --- a/backend/src/command-service.ts +++ b/backend/src/command-service.ts @@ -1,7 +1,7 @@ import { Logger } from "pino"; -import { evaluate as evaluateMathjs } from 'mathjs'; import { CommandAndResult } from "./entities/command-result.entity"; import { IRepository } from "./repositories"; +import { isValidCommand, evaluate } from "./math/evaluate"; export interface ICommandService { evaluateAndSave(clientId: string, expression: string): Promise; @@ -9,7 +9,6 @@ export interface ICommandService { } class CommandService implements ICommandService { - private operators = new Set(['+', '-', '*', '/']); private repository: IRepository; private logger: Logger; constructor(logger: Logger, repository: IRepository) { @@ -31,35 +30,10 @@ class CommandService implements ICommandService { // evaluate evaluate a mathematical expression private evaluate(expression: string): string { - if (!this.isValidCommand(expression)) { + if (!isValidCommand(expression)) { throw new Error('Invalid command'); } - return evaluateMathjs(expression).toString(); - } - - // isValidCommand checks if the command is valid - private isValidCommand(s: string): boolean { - s = s.replace(/ /g,''); // remove all spaces - if (s.length === 0) { - return false; - } - // allowed characters: 0-9, +, -, *, /, . - const regex = /^[\d+\-*/.]+$/; - if (!regex.test(s)) { - return false; - } - // operators cannot be adjacent to each other - for (let i = 0; i < s.length - 1; i++) { - if (this.operators.has(s[i]) && this.operators.has(s[i + 1])) { - return false; - } - } - - // operators cannot be at the beginning or end of the string - if (this.operators.has(s[0]) || this.operators.has(s[s.length - 1])) { - return false; - } - return true; + return evaluate(expression); } } diff --git a/backend/src/command-service.spec.ts b/backend/src/math/evaluate.spec.ts similarity index 51% rename from backend/src/command-service.spec.ts rename to backend/src/math/evaluate.spec.ts index a9fda6f..9624e4f 100644 --- a/backend/src/command-service.spec.ts +++ b/backend/src/math/evaluate.spec.ts @@ -1,10 +1,4 @@ -import CommandService from './command-service'; -import { pino } from 'pino'; - -const mockRepository = { - saveCommand: jest.fn(), - getLatest: jest.fn(), -}; +import { evaluate } from './evaluate'; type TestCase = { command: string; @@ -13,39 +7,51 @@ type TestCase = { }; describe('evaluate', () => { - const logger = pino(); describe.each([ { command: '', hasError: true }, { command: ' ', hasError: true }, - { command: '1', expected: '1' }, { command: '1+', hasError: true }, + { command: '1 */ 2', hasError: true }, { command: '2 + + 1', hasError: true }, - - { command: '1 + 1', expected: '2' }, - { command: '123 * 23 + 45 - 6', expected: '2868' }, - { command: '10 * 5 / 2', expected: '25' }, - { command: '10.5 * 5 / 2.5', expected: '21' }, - { command: '1a2b3c', hasError: true }, { command: '1 + 2 = 3', hasError: true }, { command: '(123 * 23) + 45 - 6', hasError: true }, + // // does not support negative numbers in expression + { command: '5 * -3', hasError: true }, + + // // basic + { command: '1', expected: '1' }, { command: '1 + 1', expected: '2' }, { command: '123 * 23 + 45 - 6', expected: '2868' }, + { command: '1 + 259 - 68*77', expected: '-4976' }, { command: '10 * 5 / 2', expected: '25' }, - { command: '10.54 * 7 / 3', expected: '24.593333333333334' }, + { command: '10.5 * 5 / 2.5', expected: '21' }, + { command: '.5 + 1', expected: '1.5' }, + + + // Zero cases + { command: '0 * 5', expected: '0' }, + { command: '0 / 1', expected: '0' }, + { command: '1 / 0', expected: 'Infinity' }, + { command: '0 / 0', expected: 'NaN' }, + + // decimal default precision is 20. + // ref: https://mikemcl.github.io/decimal.js/#precision + { command: '10.54 * 7 / 3', expected: '24.593333333333333333' }, { command: '5 * 7 / 3 * 3', expected: '35' }, + { command: '1 + 2 * 3 / 4', expected: '2.5' }, + + // test edge cases + { command: '999999999999 * 999999999999', expected: '9.99999999998e+23' }, ])('.evaluate($command)', ({ command, hasError, expected }) => { - test(`returns ${hasError}`, async () => { - const clientId = 'whatever'; - const commandService = new CommandService(logger, mockRepository); + test(`returns ${hasError}`, () => { if (hasError) { - await expect(commandService.evaluateAndSave(clientId, command)).rejects.toThrow('Invalid command'); + expect(() => evaluate(command)).toThrow('Invalid command'); } else { - const res = await commandService.evaluateAndSave(clientId, command); + const res = evaluate(command); expect(res).toBe(expected); - expect(mockRepository.saveCommand).toHaveBeenCalledWith(clientId, command, expected); } }); }); diff --git a/backend/src/math/evaluate.ts b/backend/src/math/evaluate.ts new file mode 100644 index 0000000..7a56203 --- /dev/null +++ b/backend/src/math/evaluate.ts @@ -0,0 +1,70 @@ +import { ExpressionMD } from './types'; +const operators = new Set(['+', '-', '*', '/']); + +// isValidCommand checks if the command is a allowed mathematical expression +function isValidCommand(s: string): boolean { + s = s.replace(/ /g,''); // remove all spaces + if (s.length === 0) { + return false; + } + // allowed characters: 0-9, +, -, *, /, . + const regex = /^[\d+\-*/.]+$/; + if (!regex.test(s)) { + return false; + } + // operators cannot be adjacent to each other + for (let i = 0; i < s.length - 1; i++) { + if (operators.has(s[i]) && operators.has(s[i + 1])) { + return false; + } + } + + // operators cannot be at the beginning or end of the string + if (operators.has(s[0]) || operators.has(s[s.length - 1])) { + return false; + } + return true; +} + +// evaluate evaluate a mathematical expression +function evaluate(s: string): string { + if (!isValidCommand(s)) { + throw new Error('Invalid command'); + } + s = s.replace(/ /g,''); // remove all spaces + + const exps = parseExpressionMDs(s).map(expMD => expMD.toFraction()); + const first = exps.shift(); + if (!first) { + return "0" + } + if (exps.length === 0) { + return first.evaluate() + } + const result = exps.reduce((acc, expMD) => acc.add(expMD), first); + return result.evaluate().toString(); +} + +// parseExpressionMDs parse the expression into list of ExpressionMD +const parseExpressionMDs = (s: string): ExpressionMD[] => { + const expressions: ExpressionMD[] = []; + let currExp = ""; + let prevOp = true; + for (const token of s.split('')) { + if (token === '+') { + expressions.push(new ExpressionMD(prevOp, currExp)); + prevOp = true + currExp = ""; + } else if (token === '-') { + expressions.push(new ExpressionMD(prevOp, currExp)); + prevOp = false; + currExp = ""; + } else { + currExp += token; + } + } + expressions.push(new ExpressionMD(prevOp, currExp)); // last one + return expressions; +} + +export { isValidCommand, evaluate }; \ No newline at end of file diff --git a/backend/src/math/types.ts b/backend/src/math/types.ts new file mode 100644 index 0000000..a6c5bb9 --- /dev/null +++ b/backend/src/math/types.ts @@ -0,0 +1,108 @@ +import { Decimal } from "decimal.js"; + +// ExpressionMD is a mathematical expression only contains numbers, multiplication, division +export class ExpressionMD { + public sign: boolean; + public exp: string; + constructor(sign: boolean, exp: string) { + this.sign = sign; + this.exp = exp; + } + + // toFraction evaluate the expression and turn into a fraction + public toFraction(): Fraction { + let numerator = new Decimal(this.firstNumber()); + if (!this.sign) { + numerator = numerator.neg(); + } + let denominator = new Decimal(1); + const indexOfFirstOperator = this.firstNumber().length; + // only one number + if (indexOfFirstOperator === this.exp.length) { + return new Fraction(numerator, new Decimal(1)); + } + let prevOp = this.exp[indexOfFirstOperator]; + let currStr = ""; + for (let i = indexOfFirstOperator + 1; i < this.exp.length; i++) { + if (this.exp[i] === '*' || this.exp[i] === '/') { + switch (prevOp) { + case '*': + numerator = numerator.mul(new Decimal(currStr)); + break; + case '/': + denominator = denominator.mul(new Decimal(currStr)); + break; + default: + throw new Error('Invalid operator'); + } + prevOp = this.exp[i]; + currStr = ""; + }else{ + currStr += this.exp[i]; + } + } + // last one + if (prevOp === '*') { + numerator = numerator.mul(new Decimal(currStr)); + } else if (prevOp === '/') { + denominator = denominator.mul(new Decimal(currStr)); + } else { + throw new Error('Invalid operator'); + } + + return new Fraction(numerator, denominator); + } + + // firstNumber return the first number in the expression + private firstNumber(): string { + for (let i = 0; i < this.exp.length; i++) { + if (this.exp[i] === '*') { + return this.exp.substring(0, i); + } + if (this.exp[i] === '/') { + return this.exp.substring(0, i); + } + } + return this.exp; + } +} + +export class Fraction { + public numerator: Decimal; + public denominator: Decimal; + + constructor(numerator: Decimal, denominator: Decimal) { + this.numerator = numerator; + this.denominator = denominator; + } + + public add(f: Fraction): Fraction { + const lcm = getLcm(this.denominator, f.denominator); + const multiplyThis = lcm.div(this.denominator); + const multiplyF = lcm.div(f.denominator); + this.numerator = this.numerator.mul(multiplyThis); + this.denominator = lcm; + f.numerator = f.numerator.mul(multiplyF); + f.denominator = lcm; + const sum = this.numerator.add(f.numerator); + return new Fraction(sum, lcm); + } + + public evaluate(): string { + return this.numerator.div(this.denominator).toString(); + } +} + +function getLcm(a: Decimal, b: Decimal): Decimal { + return a.mul(b).div(getGcd(a, b)); +} + +function getGcd(a: Decimal, b: Decimal): Decimal { + const max = Decimal.max(a, b); + const min = Decimal.min(a, b); + if (max.mod(min).eq(0)) { + return min; + } else { + return getGcd(max.mod(min), min); + } +} \ No newline at end of file