expression

This commit is contained in:
Evgeny @ SimpleX Chat
2026-09-25 09:57:38 +00:00
parent 099be349ab
commit d2c3162453
5 changed files with 116 additions and 42 deletions
+2 -2
View File
@@ -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.
+29 -3
View File
@@ -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()
})
})
@@ -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()
+82 -33
View File
@@ -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<string, Key> = {"c": "C", "*": "×", "x": "×", "/": "÷", "−": "-"}
const operatorNames = new Map<string, Operator>([
["+", "+"], ["-", "-"], ["−", "-"], ["×", "×"], ["x", "×"], ["*", "×"], ["÷", "÷"], ["/", "÷"],
])
export const keyNames = new Map<string, Key>([
...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)
@@ -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