diff --git a/apps/simplex-calculator-bot/README.md b/apps/simplex-calculator-bot/README.md index b7110bffa8..7075862b3f 100644 --- a/apps/simplex-calculator-bot/README.md +++ b/apps/simplex-calculator-bot/README.md @@ -4,11 +4,11 @@ A calculator bot built with the [SimpleX Chat Node.js library](../../packages/si Each user who connects via the bot's business address receives a calculator message with keys as commands. The bot replaces this message after each input. -- Keys are applied left to right, so `2 + 3 × 4 =` equals 20. +- `×` and `÷` are applied before `+` and `-`, so `2 + 3 × 4 =` equals 14. - `%` is computed as in Apple's calculator: `100 + 15 % =` equals 115. - Tap `C` to clear the number, and `C` again to clear the calculation. - Numbers are shown without exponent, up to 15 digits; larger results are shown as `Error`. -- You can also send numbers, expressions like `12 × 3 + 4`, which are entered as one number, and keys like `+`, `=`, `add` or `c`. +- You can also send numbers, expressions like `(2 + 3) × 4`, which are entered as one number, and keys like `+`, `=`, `add` or `c`. - The bot deletes tapped keys and sends each calculation after `=`. - After 10 minutes without input, the bot turns the calculator off and discards the calculation; tap any key to turn it on. - The bot sends keys like `/+` to apps with chat protocol version 21 or later, and keys like `/add` to older apps. diff --git a/apps/simplex-calculator-bot/calculator.test.ts b/apps/simplex-calculator-bot/calculator.test.ts index 7c5d15ac8a..d45bb98e83 100644 --- a/apps/simplex-calculator-bot/calculator.test.ts +++ b/apps/simplex-calculator-bot/calculator.test.ts @@ -25,10 +25,20 @@ describe("keys", () => { expect(display("1 . . 5")).toBe("1.5") }) - test("compute left to right and log after equals", () => { + test("compute × and ÷ before + and -", () => { expect(tap("2 + 2 =")).toEqual({calc: expect.objectContaining({display: "4"}), logLines: ["2 + 2 = 4"]}) - expect(tap("2 + 3 × 4 =").logLines).toEqual(["2 + 3 × 4 = 20"]) - expect(display("2 + 3 ×")).toBe("5") + expect(tap("2 + 3 × 4 =").logLines).toEqual(["2 + 3 × 4 = 14"]) + expect(tap("2 × 3 + 4 =").logLines).toEqual(["2 × 3 + 4 = 10"]) + expect(display("1 0 - 2 × 3 =")).toBe("4") + expect(display("1 0 - 2 - 3 =")).toBe("5") + expect(display("8 ÷ 4 ÷ 2 =")).toBe("1") + }) + + test("show the operand of the pressed operator", () => { + expect(display("2 + 3 ×")).toBe("3") + expect(display("2 × 3 +")).toBe("6") + expect(display("2 + 3 × 4 ×")).toBe("12") + expect(display("2 + 3 × 4 +")).toBe("14") }) test("log only after equals", () => { @@ -38,6 +48,8 @@ describe("keys", () => { test("replace operator", () => { expect(tap("2 + × 3 =").logLines).toEqual(["2 × 3 = 6"]) + expect(tap("2 + 3 × + 4 =").logLines).toEqual(["2 + 3 + 4 = 9"]) + expect(tap("2 × 3 + × 4 =").logLines).toEqual(["2 × 3 × 4 = 24"]) }) test("continue from result", () => { @@ -55,6 +67,9 @@ describe("keys", () => { expect(display("5 0 ÷ 1 0 %")).toBe("0.1") expect(display("5 0 ÷ 1 0 % =")).toBe("500") expect(display("1 5 %")).toBe("0.15") + expect(display("1 0 0 + 2 × 1 5 %")).toBe("0.15") + expect(display("1 0 0 + 2 × 1 5 % =")).toBe("100.3") + expect(display("2 × 3 + 1 0 %")).toBe("0.6") }) test("square root and sign", () => { @@ -117,6 +132,12 @@ describe("typed messages", () => { expect(result("2 × -3")).toBe("-6") expect(result("100 + 15%")).toBe("115") expect(result("42")).toBe("42") + expect(result("2 + 3 × 4")).toBe("14") + expect(result("(2 + 3) × 4")).toBe("20") + expect(result("2 × (3 + 4)")).toBe("14") + expect(result("((1 + 2) × 3)")).toBe("9") + expect(result("-(2 + 3)")).toBe("-5") + expect(result("100 + (10 + 5)%")).toBe("115") expect(type("10", "×", "2 + 3", "=").logLines).toEqual(["10 × 5 = 50"]) expect(type("10", "+", "25=").calc.display).toBe("35") }) @@ -130,6 +151,11 @@ describe("typed messages", () => { expect(textInput("hello")).toBeUndefined() expect(textInput("2 ^ 3")).toBeUndefined() expect(textInput("2.3.4")).toBeUndefined() + expect(textInput("(2 + 3")).toBeUndefined() + expect(textInput("2 + 3)")).toBeUndefined() + expect(textInput("()")).toBeUndefined() + expect(textInput("2(3)")).toBeUndefined() + expect(textInput("2 +")).toBeUndefined() }) }) diff --git a/apps/simplex-calculator-bot/calculatorBot.test.ts b/apps/simplex-calculator-bot/calculatorBot.test.ts index 8d43e90fe1..aed83d45e7 100644 --- a/apps/simplex-calculator-bot/calculatorBot.test.ts +++ b/apps/simplex-calculator-bot/calculatorBot.test.ts @@ -54,7 +54,7 @@ test("calculator in business chat", async () => { for (const event of received) expect(await event).toBeDefined() } await send(["/2", "/+", "/2", "/="], hasText("2 + 2 = 4"), calculatorShows("4")) - await send(["12 × 3 + 4"], calculatorShows("40")) + await send(["2 × (3 + 4) - 1"], calculatorShows("13")) await send(["25", "+", "25", "="], hasText("25 + 25 = 50"), calculatorShows("50")) } finally { await alice.close() diff --git a/apps/simplex-calculator-bot/src/calculator.ts b/apps/simplex-calculator-bot/src/calculator.ts index f9e52ec9af..f27fc840b5 100644 --- a/apps/simplex-calculator-bot/src/calculator.ts +++ b/apps/simplex-calculator-bot/src/calculator.ts @@ -6,11 +6,16 @@ export type Update = (calc: Calc) => [Calc, string?] export interface Calc { display: string operand: string - terms: string[] - pending?: {acc: number, op: Operator} + terms: Term[] mode: "typing" | "result" | "operator" } +interface Term { + operand: string + value: number + op: Operator +} + export const initialCalc: Calc = {display: "0", operand: "0", terms: [], mode: "typing"} const keypad: Key[][] = [ @@ -37,11 +42,14 @@ function keyWord(key: Key): string { return keyWords[key] ?? key } -const keyAliases: Record = {"c": "C", "*": "×", "x": "×", "/": "÷", "−": "-"} +const operatorNames = new Map([ + ["+", "+"], ["-", "-"], ["−", "-"], ["×", "×"], ["x", "×"], ["*", "×"], ["÷", "÷"], ["/", "÷"], +]) export const keyNames = new Map([ ...keypad.flat().flatMap((key): [string, Key][] => [[key, key], [keyWord(key), key]]), - ...Object.entries(keyAliases), + ...operatorNames, + ["c", "C"], ]) const maxDigits = 15 @@ -82,9 +90,9 @@ function negate(calc: Calc): Calc { } function percent(calc: Calc): number { - const {pending} = calc - const b = value(calc) - return pending && (pending.op === "+" || pending.op === "-") ? pending.acc * b / 100 : b / 100 + const last = calc.terms.at(-1) + const share = value(calc) / 100 + return last && isAdditive(last.op) ? leftOperand(calc.terms) * share : share } function showNumber(calc: Calc, n: number, operand?: string): Calc { @@ -93,26 +101,46 @@ function showNumber(calc: Calc, n: number, operand?: string): Calc { } function operator(calc: Calc, op: Operator): Calc { - if (calc.mode === "operator" && calc.pending) { - return {...calc, pending: {...calc.pending, op}, terms: [...calc.terms.slice(0, -1), op]} - } - const display = format(calc.pending ? apply(calc.pending, value(calc)) : value(calc)) - return {display, operand: display, terms: [...calc.terms, calc.operand, op], pending: {acc: Number(display), op}, mode: "operator"} + const terms = calc.mode === "operator" + ? [...calc.terms.slice(0, -1), {...calc.terms[calc.terms.length - 1], op}] + : [...calc.terms, {operand: calc.operand, value: value(calc), op}] + const display = format(leftOperand(terms)) + return {display, operand: display, terms, mode: "operator"} } function equals(calc: Calc): [Calc, string?] { - if (!calc.pending) return [calc] - const display = format(apply(calc.pending, value(calc))) - const logLine = [...calc.terms, calc.operand, "=", display].join(" ") + if (calc.terms.length === 0) return [calc] + const display = format(evaluate(calc.terms, value(calc)).total) + const logLine = [...calc.terms.flatMap(term => [term.operand, term.op]), calc.operand, "=", display].join(" ") return [{display, operand: display, terms: [], mode: "result"}, logLine] } -function apply({acc, op}: {acc: number, op: Operator}, b: number): number { +function leftOperand(terms: Term[]): number { + const {value, op} = terms[terms.length - 1] + const {total, product} = evaluate(terms.slice(0, -1), value) + return isAdditive(op) ? total : product +} + +function evaluate(terms: Term[], last: number): {total: number, product: number} { + const sumEnd = terms.map(term => isAdditive(term.op)).lastIndexOf(true) + const factors = terms.slice(sumEnd + 1) + const values = [...factors.map(term => term.value), last] + const product = factors.reduce((acc, {op}, i) => apply(acc, op, values[i + 1]), values[0]) + if (sumEnd < 0) return {total: product, product} + const {value, op} = terms[sumEnd] + return {total: apply(evaluate(terms.slice(0, sumEnd), value).total, op, product), product} +} + +function isAdditive(op: Operator): boolean { + return op === "+" || op === "-" +} + +function apply(a: number, op: Operator, b: number): number { switch (op) { - case "+": return acc + b - case "-": return acc - b - case "×": return acc * b - case "÷": return acc / b + case "+": return a + b + case "-": return a - b + case "×": return a * b + case "÷": return a / b } } @@ -142,20 +170,41 @@ export function textInput(text: string): Update | undefined { return key ? calc => press(calc, key) : undefined } -const termPattern = /([-+−×x*÷\/]?)([-−]?)(\d+(?:\.\d*)?|\.\d+)(%?)/g +const tokenPattern = /\d+(?:\.\d*)?|\.\d+|[-+−×x*÷\/%()]/g -function expressionValue(expression: string): number | undefined { - const terms = [...expression.matchAll(termPattern)] - const valid = terms.length > 0 - && terms.map(([term]) => term).join("") === expression - && terms.every(([, op], i) => i === 0 || op !== "") - if (!valid) return undefined - const calc = terms.reduce((current, [, op, sign, number, percentSign]) => { - const withOperator = op ? press(current, keyAliases[op] ?? op as Key)[0] : current - const entered = showNumber(withOperator, sign ? -Number(number) : Number(number)) - return percentSign ? press(entered, "%")[0] : entered - }, initialCalc) - return value(press(calc, "=")[0]) +interface Parsed { + value: number + rest: string[] +} + +function expressionValue(text: string): number | undefined { + const tokens = text.match(tokenPattern) ?? [] + const parsed = tokens.join("") === text ? expression(tokens, initialCalc) : undefined + return parsed?.rest.length === 0 ? parsed.value : undefined +} + +function expression(tokens: string[], calc: Calc): Parsed | undefined { + const operand = term(tokens) + if (!operand) return undefined + const percent = operand.rest[0] === "%" + const shown = showNumber(calc, operand.value) + const entered = percent ? press(shown, "%")[0] : shown + const rest = percent ? operand.rest.slice(1) : operand.rest + const op = operatorNames.get(rest[0]) + return op ? expression(rest.slice(1), press(entered, op)[0]) : {value: value(press(entered, "=")[0]), rest} +} + +function term([first, ...rest]: string[]): Parsed | undefined { + if (first === "(") { + const inner = expression(rest, initialCalc) + return inner?.rest[0] === ")" ? {value: inner.value, rest: inner.rest.slice(1)} : undefined + } + if (operatorNames.get(first) === "-") { + const operand = term(rest) + return operand && {value: -operand.value, rest: operand.rest} + } + const n = Number(first) + return Number.isNaN(n) ? undefined : {value: n, rest} } const nbsp = String.fromCharCode(0xa0) diff --git a/apps/simplex-calculator-bot/src/calculatorBot.ts b/apps/simplex-calculator-bot/src/calculatorBot.ts index 1c0daa9499..1f6acb0ca8 100644 --- a/apps/simplex-calculator-bot/src/calculatorBot.ts +++ b/apps/simplex-calculator-bot/src/calculatorBot.ts @@ -6,10 +6,9 @@ import {calculatorIcon} from "./icon.js" const anyTextCommandsVersion = 21 const idleMinutes = 10 -const welcomeMessage = `Tap the keys or send numbers, keys like + and =, or expressions like 12 × 3 + 4. -Keys are applied left to right, as on a pocket calculator. +const welcomeMessage = `Tap the keys or send numbers, keys like + and =, or expressions like (2 + 3) × 4. The calculator turns off after ${idleMinutes} minutes; any key turns it on.` -const hint = "Send a number, a key like + or =, or an expression like 12 × 3 + 4." +const hint = "Send a number, a key like + or =, or an expression like (2 + 3) × 4." interface Session { calc?: Calc