diff --git a/backend/package-lock.json b/backend/package-lock.json index b2a8c39..714221c 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -13,6 +13,7 @@ "@types/express": "^5.0.0", "@types/node": "^22.10.7", "express": "^4.21.2", + "mathjs": "^14.0.1", "pino": "^9.6.0", "socket.io": "^4.8.1" }, @@ -474,6 +475,18 @@ "@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", @@ -2562,6 +2575,19 @@ "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", @@ -2741,6 +2767,12 @@ } } }, + "node_modules/decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", + "license": "MIT" + }, "node_modules/dedent": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", @@ -3193,6 +3225,12 @@ "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", @@ -3756,6 +3794,19 @@ "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", @@ -4804,6 +4855,12 @@ "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", @@ -5670,6 +5727,29 @@ "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", @@ -6535,6 +6615,12 @@ "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", @@ -6752,6 +6838,12 @@ "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", @@ -7392,6 +7484,12 @@ "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", @@ -7667,6 +7765,15 @@ "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 bc3d946..619df5c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -16,6 +16,7 @@ "@types/express": "^5.0.0", "@types/node": "^22.10.7", "express": "^4.21.2", + "mathjs": "^14.0.1", "pino": "^9.6.0", "socket.io": "^4.8.1" }, diff --git a/backend/src/command-service.spec.ts b/backend/src/command-service.spec.ts new file mode 100644 index 0000000..9371c02 --- /dev/null +++ b/backend/src/command-service.spec.ts @@ -0,0 +1,44 @@ +import CommandService from './command-service'; +import { pino } from 'pino'; + +const mockRepository = { + saveCommand: jest.fn(), + getLatest: jest.fn(), +}; + +describe('evaluate', () => { + describe.each([ + { command: '', hasError: true }, + { command: ' ', hasError: true }, + { command: '1', expected: '1' }, + { command: '1+', 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 }, + + { command: '1 + 1', expected: '2' }, + { command: '123 * 23 + 45 - 6', expected: '2868' }, + { command: '10 * 5 / 2', expected: '25' }, + { command: '10.54 * 7 / 3', expected: '24.593333333333334' }, + { command: '5 * 7 / 3 * 3', expected: '35' }, + + ])('.evaluate($command)', ({ command, hasError, expected }) => { + test(`returns ${hasError}`, () => { + const clientId = 'whatever'; + const commandService = new CommandService(pino(), mockRepository); + if (hasError) { + expect(() => commandService.evaluateAndSave(clientId, command)).toThrow('Invalid command'); + } else { + expect(commandService.evaluateAndSave(clientId, command)).toBe(expected); + expect(mockRepository.saveCommand).toHaveBeenCalledWith(clientId, command, expected); + } + }); + }); +}); \ No newline at end of file diff --git a/backend/src/command-service.ts b/backend/src/command-service.ts new file mode 100644 index 0000000..6bb0f45 --- /dev/null +++ b/backend/src/command-service.ts @@ -0,0 +1,67 @@ +import { Logger } from "pino"; +import { evaluate as evaluateMathjs } from 'mathjs'; +import { CommandAndResult } from "./entities/command-result.entity"; + +interface IRepository { + saveCommand(clientId: string, command: string, result: string): void; + getLatest(clientId: string, count: number): CommandAndResult[]; +} + +const historyCount = 10; + +class CommandService { + private operators = new Set(['+', '-', '*', '/']); + private repository: IRepository; + private logger: Logger; + constructor(logger: Logger, repository: IRepository) { + this.logger = logger; + this.repository = repository; + } + + // evaluateAndSave evaluates a mathematical expression and saves the result to the repository + public evaluateAndSave(clientId: string, expression: string): string { + const result = this.evaluate(expression); + this.repository.saveCommand(clientId, expression, result); + return result; + } + + // getHistory returns the latest 10 commands for a client + public getHistory(clientId: string): CommandAndResult[] { + return this.repository.getLatest(clientId, historyCount); + } + + // evaluate evaluate a mathematical expression + private evaluate(expression: string): string { + if (!this.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; + } +} + +export default CommandService; \ No newline at end of file diff --git a/backend/src/entities/command-result.entity.ts b/backend/src/entities/command-result.entity.ts new file mode 100644 index 0000000..b6e94a1 --- /dev/null +++ b/backend/src/entities/command-result.entity.ts @@ -0,0 +1,5 @@ +// CommandAndResult is a round trip of a command and its result +export type CommandAndResult = { + expression: string; + result: string; +}