From c02d9f21e6c6bd3b3285dc6f5dbb97666e96bbb9 Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Fri, 25 Sep 2026 06:45:25 +0000 Subject: [PATCH] simplify --- apps/simplex-calculator-bot/README.md | 8 +- .../simplex-calculator-bot/calculator.test.ts | 80 ++++++++++++++----- .../calculatorBot.test.ts | 6 ++ apps/simplex-calculator-bot/src/calculator.ts | 79 ++++++++++++------ .../src/calculatorBot.ts | 51 +++++++----- 5 files changed, 158 insertions(+), 66 deletions(-) diff --git a/apps/simplex-calculator-bot/README.md b/apps/simplex-calculator-bot/README.md index ca86bc18f0..3d97c1eb53 100644 --- a/apps/simplex-calculator-bot/README.md +++ b/apps/simplex-calculator-bot/README.md @@ -8,10 +8,14 @@ Each user who connects via the bot's business address gets their own chat with a - Keys are applied left to right, without operator priority: `2 + 3 × 4 =` gives 20. - `%` follows Apple's calculator: `100 + 15 %` shows 15, and `=` gives 115; `50 × 10 %` shows 0.1, and `=` gives 5. +- `C` clears the current number; the second `C` clears the whole calculation. +- The display shows up to 15 digits, without exponent; larger results show `Error`. - After `=`, the bot sends the calculation as a message, e.g. `2 + 2 = 4`. - Tapped keys are deleted from the chat; the calculation messages stay as a log. -- Users can also send a number or an expression, e.g. `12 × 3 + 4`; the calculator shows the result. -- The calculator turns off after 10 minutes without use. Tap `/calc` or send an expression to turn it on. +- Users can also send messages instead of tapping keys: + - a number or an expression, e.g. `12 × 3 + 4`, is computed and entered as the current number, so `25`, `/add`, `25`, `=` gives 50. + - a key, e.g. `+`, `=`, `x`, `*`, `/`, `add` or `c`, works as the tapped key. +- The calculator turns off after 10 minutes without use: the number is removed from the display, the keys stay. Any key, number or `/calc` turns it on. - Apps that support commands made of symbols (chat protocol version 21) get keys like `/+`; older apps get keys like `/add`. ## Install & build diff --git a/apps/simplex-calculator-bot/calculator.test.ts b/apps/simplex-calculator-bot/calculator.test.ts index 1e070697dc..a8350fee40 100644 --- a/apps/simplex-calculator-bot/calculator.test.ts +++ b/apps/simplex-calculator-bot/calculator.test.ts @@ -1,19 +1,21 @@ import {describe, test, expect} from "vitest" -import {Calc, Key, calculatorText, evaluate, initialCalc, press, textKeys} from "./src/calculator.js" +import {Calc, Key, calculatorText, initialCalc, press, textInput} from "./src/calculator.js" -function tap(keys: string): {calc: Calc, logLines: string[]} { +type Input = {tap: string} | {text: string} + +function run(inputs: Input[]): {calc: Calc, logLines: string[]} { const logLines: string[] = [] - const calc = (keys.split(" ") as Key[]).reduce((current, key) => { - const [next, logLine] = press(current, key) + const calc = inputs.reduce((current, input) => { + const [next, logLine] = "tap" in input ? press(current, input.tap as Key) : textInput(input.text)!(current) if (logLine) logLines.push(logLine) return next }, initialCalc) return {calc, logLines} } -function display(keys: string): string { - return tap(keys).calc.display -} +const tap = (keys: string) => run(keys.split(" ").map(key => ({tap: key}))) +const type = (...texts: string[]) => run(texts.map(text => ({text}))) +const display = (keys: string) => tap(keys).calc.display describe("keys", () => { test("enter numbers", () => { @@ -60,42 +62,74 @@ describe("keys", () => { expect(tap("1 + 9 √ =").logLines).toEqual(["1 + √9 = 4"]) expect(display("5 ±")).toBe("-5") expect(display("5 ± ±")).toBe("5") + expect(display("± 5")).toBe("-5") + expect(display("2 + 2 = ±")).toBe("-4") }) - test("rounding", () => { + test("clear entry, then clear all", () => { + expect(tap("2 + 3 C 4 =").logLines).toEqual(["2 + 4 = 6"]) + expect(tap("2 + 3 C C").calc).toEqual(initialCalc) + }) + + test("display without exponent", () => { expect(display("0 . 1 + 0 . 2 =")).toBe("0.3") - expect(display("1 ÷ 3 =")).toBe("0.333333333333") + expect(display("1 ÷ 3 =")).toBe("0.33333333333333") + expect(display("1 ÷ 1 0 0 0 0 0 0 0 =")).toBe("0.0000001") + expect(display("1 ÷ 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 =")).toBe("0.00000000000001") + expect(display(". 0 0 0 0 0 0 0 1 × . 0 0 0 0 0 0 0 1 =")).toBe("0") + }) + + test("limit digits and overflow", () => { + expect(display("1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7")).toBe("123456789012345") + expect(display("9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 + 1 =")).toBe("Error") }) test("errors", () => { expect(display("1 ÷ 0 =")).toBe("Error") expect(display("2 ± √")).toBe("Error") expect(display("1 ÷ 0 = 5")).toBe("5") - expect(display("1 ÷ 0 = + 2 =")).toBe("2") - }) - - test("clear", () => { - expect(tap("2 + 3 C").calc).toEqual(initialCalc) + expect(display("1 ÷ 0 + 2 =")).toBe("Error") + expect(tap("1 ÷ 0 = C").calc).toEqual(initialCalc) }) }) -describe("typed expressions", () => { - test("evaluate", () => { - const result = (text: string) => evaluate(textKeys(text)!).display +describe("typed messages", () => { + test("numbers and keys", () => { + expect(type("25", "+", "25", "=")).toEqual({calc: expect.objectContaining({display: "50"}), logLines: ["25 + 25 = 50"]}) + expect(type("25", "add", "25", "eq").logLines).toEqual(["25 + 25 = 50"]) + expect(type("6", "x", "7", "=").logLines).toEqual(["6 × 7 = 42"]) + expect(type("5", "*", "5", "/", "2", "=").logLines).toEqual(["5 × 5 ÷ 2 = 12.5"]) + expect(type("25", "+", "3", "c", "4", "=").logLines).toEqual(["25 + 4 = 29"]) + }) + + test("mixed with taps", () => { + expect(run([{text: "25"}, {tap: "+"}, {text: "25"}, {tap: "="}]).logLines).toEqual(["25 + 25 = 50"]) + }) + + test("expressions are entered as a number", () => { + const result = (text: string) => type(text).calc.display expect(result("2 + 2")).toBe("4") expect(result("12 × 3 + 4")).toBe("40") expect(result("12 * 3 + 4 =")).toBe("40") expect(result("8 / 2")).toBe("4") expect(result("3x3")).toBe("9") expect(result("-5")).toBe("-5") + expect(result("2 × -3")).toBe("-6") expect(result("100 + 15%")).toBe("115") expect(result("42")).toBe("42") + expect(type("10", "×", "2 + 3", "=").logLines).toEqual(["10 × 5 = 50"]) + expect(type("10", "+", "25=").calc.display).toBe("35") + }) + + test("overflow in typed numbers", () => { + expect(type("1234567890123456").calc.display).toBe("Error") + expect(type("123456789012345").calc.display).toBe("123456789012345") }) test("reject other text", () => { - expect(textKeys("hello")).toBeUndefined() - expect(textKeys("+")).toBeUndefined() - expect(textKeys("2 ^ 3")).toBeUndefined() + expect(textInput("hello")).toBeUndefined() + expect(textInput("2 ^ 3")).toBeUndefined() + expect(textInput("2.3.4")).toBeUndefined() }) }) @@ -111,6 +145,12 @@ describe("calculator text", () => { ].join("\n")) }) + test("switched off: keys without number", () => { + const [displayLine, ...rows] = calculatorText(undefined, true).split("\n") + expect(displayLine).toBe("* *") + expect(rows).toEqual(calculatorText(initialCalc, true).split("\n").slice(1)) + }) + test("word keys padded to equal width", () => { const pad = (n: number) => `\`${" ".repeat(n)}\`` const [displayLine, firstRow] = calculatorText(initialCalc, false).split("\n") diff --git a/apps/simplex-calculator-bot/calculatorBot.test.ts b/apps/simplex-calculator-bot/calculatorBot.test.ts index effb491aad..d92390ed16 100644 --- a/apps/simplex-calculator-bot/calculatorBot.test.ts +++ b/apps/simplex-calculator-bot/calculatorBot.test.ts @@ -58,6 +58,12 @@ test("calculator in business chat", async () => { const forty = alice.wait("newChatItems", calculatorShows("40"), 30000) await alice.apiSendTextMessage([T.ChatType.Group, groupId], "12 × 3 + 4") expect(await forty).toBeDefined() + + const typedLogLine = alice.wait("newChatItems", hasText("25 + 25 = 50"), 30000) + const fifty = alice.wait("newChatItems", calculatorShows("50"), 30000) + for (const text of ["25", "+", "25", "="]) await alice.apiSendTextMessage([T.ChatType.Group, groupId], text) + expect(await typedLogLine).toBeDefined() + expect(await fifty).toBeDefined() } finally { await alice.close() await calculator.close() diff --git a/apps/simplex-calculator-bot/src/calculator.ts b/apps/simplex-calculator-bot/src/calculator.ts index e755b59e0a..189f2a956c 100644 --- a/apps/simplex-calculator-bot/src/calculator.ts +++ b/apps/simplex-calculator-bot/src/calculator.ts @@ -1,6 +1,7 @@ export type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" export type Operator = "+" | "-" | "×" | "÷" export type Key = Digit | Operator | "C" | "±" | "%" | "√" | "." | "=" +export type Update = (calc: Calc) => [Calc, string?] export interface Calc { display: string @@ -10,7 +11,7 @@ export interface Calc { mode: "typing" | "result" | "operator" } -export const initialCalc: Calc = {display: "0", operand: "0", terms: [], mode: "result"} +export const initialCalc: Calc = {display: "0", operand: "0", terms: [], mode: "typing"} export const keypad: Key[][] = [ ["C", "±", "%", "÷"], @@ -36,19 +37,25 @@ export function keyWord(key: Key): string { return keyWords[key] ?? key } -const maxDigits = 12 +const keyAliases: Record = {"c": "C", "*": "×", "x": "×", "/": "÷", "−": "-"} + +export const keyNames = new Map([ + ...keypad.flat().flatMap((key): [string, Key][] => [[key, key], [keyWord(key), key]]), + ...Object.entries(keyAliases), +]) + +const maxDigits = 15 const errorDisplay = "Error" export function press(calc: Calc, key: Key): [Calc, string?] { - const current = calc.display === errorDisplay ? initialCalc : calc switch (key) { - case "C": return [initialCalc] - case "=": return equals(current) - case "+": case "-": case "×": case "÷": return [operator(current, key)] - case "%": return [result(current, percent(current), `${current.operand}%`)] - case "√": return [result(current, Math.sqrt(value(current)), `√${current.operand}`)] - case "±": return [negate(current)] - default: return [enter(current, key)] + case "C": return [calc.display === "0" || calc.display === errorDisplay ? initialCalc : {...calc, display: "0", operand: "0", mode: "typing"}] + case "=": return equals(calc) + case "+": case "-": case "×": case "÷": return [operator(calc, key)] + case "%": return [result(calc, percent(calc), `${calc.operand}%`)] + case "√": return [result(calc, Math.sqrt(value(calc)), `√${calc.operand}`)] + case "±": return [negate(calc)] + default: return [enter(calc, key)] } } @@ -64,8 +71,9 @@ function append(display: string, key: Digit | "."): string { } function negate(calc: Calc): Calc { + if (calc.mode !== "typing") return enterNumber(calc, -value(calc)) const display = calc.display.startsWith("-") ? calc.display.slice(1) : `-${calc.display}` - return {...calc, display, operand: display, mode: calc.mode === "typing" ? "typing" : "result"} + return {...calc, display, operand: display} } function percent(calc: Calc): number { @@ -74,6 +82,10 @@ function percent(calc: Calc): number { return pending && (pending.op === "+" || pending.op === "-") ? pending.acc * b / 100 : b / 100 } +function enterNumber(calc: Calc, n: number): Calc { + return result(calc, n, format(n)) +} + function result(calc: Calc, n: number, operand: string): Calc { return {...calc, display: format(n), operand, mode: "result"} } @@ -107,28 +119,49 @@ function value(calc: Calc): number { } function format(n: number): string { - return Number.isFinite(n) ? String(Number(n.toPrecision(maxDigits))) : errorDisplay + const rounded = Number(n.toPrecision(maxDigits)) + if (!Number.isFinite(rounded) || Math.abs(rounded) >= 10 ** maxDigits) return errorDisplay + const integerDigits = Math.trunc(Math.abs(rounded)).toString().length + const fixed = rounded.toFixed(maxDigits - integerDigits) + const trimmed = fixed.includes(".") ? fixed.replace(/\.?0+$/, "") : fixed + return trimmed === "-0" ? "0" : trimmed } -const typedKeys: Record = {"*": "×", "x": "×", "/": "÷", "−": "-"} - -export function textKeys(text: string): Key[] | undefined { - const expression = text.replace(/\s/g, "").replace(/=$/, "") - if (!/^[\d.+\-−×x*÷\/%]+$/.test(expression) || !/\d/.test(expression)) return undefined - return [...expression].map(c => typedKeys[c] ?? c as Key) +export function textInput(text: string): Update | undefined { + const input = text.replace(/\s/g, "") + const n = expressionValue(input.replace(/=$/, "")) + if (n !== undefined) { + return calc => { + const entered = enterNumber(calc, n) + return input.endsWith("=") ? press(entered, "=") : [entered] + } + } + const key = keyNames.get(input.toLowerCase()) + return key ? calc => press(calc, key) : undefined } -export function evaluate(keys: Key[]): Calc { - const allKeys: Key[] = [...keys, "="] - return allKeys.reduce((calc, key) => press(calc, key)[0], initialCalc) +const termPattern = /([-+−×x*÷\/]?)([-−]?)(\d+(?:\.\d*)?|\.\d+)(%?)/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 = enterNumber(withOperator, sign ? -Number(number) : Number(number)) + return percentSign ? press(entered, "%")[0] : entered + }, initialCalc) + return value(press(calc, "=")[0]) } const nbsp = " " const wordWidth = Math.max(...keypad.flat().map(key => keyWord(key).length)) -export function calculatorText(calc: Calc, symbolKeys: boolean): string { +export function calculatorText(calc: Calc | undefined, symbolKeys: boolean): string { const rows = keypad.map(row => symbolKeys ? row.map(key => `/${key}`).join(" ") : row.map(wordKey).join("")) - return [`*${calc.display}*`, ...rows].join("\n") + return [`*${calc?.display ?? nbsp}*`, ...rows].join("\n") } function wordKey(key: Key): string { diff --git a/apps/simplex-calculator-bot/src/calculatorBot.ts b/apps/simplex-calculator-bot/src/calculatorBot.ts index 537362021a..c62d82acff 100644 --- a/apps/simplex-calculator-bot/src/calculatorBot.ts +++ b/apps/simplex-calculator-bot/src/calculatorBot.ts @@ -1,23 +1,29 @@ import {T} from "@simplex-chat/types" import {api, bot, util} from "simplex-chat" -import {Calc, calculatorText, evaluate, initialCalc, keyWord, keypad, press, textKeys} from "./calculator.js" +import {Calc, Update, calculatorText, initialCalc, keyNames, press, textInput} from "./calculator.js" +import {calculatorIcon} from "./icon.js" const anyTextCommandsVersion = 21 const idleMinutes = 10 -const welcomeMessage = `Tap the keys, or send an expression like 12 × 3 + 4.\nKeys are applied left to right, as on a pocket calculator.\nThe calculator turns off after ${idleMinutes} minutes.` -const offText = "*Off*\nTap /calc or send an expression." -const hint = "Send a number or an expression like 12 × 3 + 4." +const welcomeMessage = `Tap the keys, or send numbers, keys like + or =, and expressions like 12 × 3 + 4.\nKeys are applied left to right, as on a pocket calculator.\nThe 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." interface Session { - calc: Calc + calc?: Calc itemId: number - timer: NodeJS.Timeout + symbolKeys: boolean + timer?: NodeJS.Timeout +} + +interface Sender { + groupId: number + member: T.GroupMember } const sessions = new Map() -function groupSender({chatInfo, chatItem}: T.AChatItem): {groupId: number, member: T.GroupMember} | undefined { +function groupSender({chatInfo, chatItem}: T.AChatItem): Sender | undefined { return chatInfo.type === "group" && chatItem.chatDir.type === "groupRcv" ? {groupId: chatInfo.groupInfo.groupId, member: chatItem.chatDir.groupMember} : undefined @@ -32,45 +38,48 @@ async function showCalculator(chat: api.ChatApi, groupId: number, member: T.Grou const [sent] = await chat.apiSendTextMessage([T.ChatType.Group, groupId], calculatorText(calc, symbolKeys)) const itemId = sent.chatItem.meta.itemId const previous = sessions.get(groupId) - sessions.set(groupId, {calc, itemId, timer: setTimeout(() => turnOff(chat, groupId, itemId), idleMinutes * 60_000)}) + const timer = setTimeout(() => turnOff(chat, groupId, itemId, symbolKeys), idleMinutes * 60_000) + sessions.set(groupId, {calc, itemId, symbolKeys, timer}) if (previous) { clearTimeout(previous.timer) await chat.apiDeleteChatItems(T.ChatType.Group, groupId, [previous.itemId], T.CIDeleteMode.Broadcast) } } -function turnOff(chat: api.ChatApi, groupId: number, itemId: number): void { - sessions.delete(groupId) - chat.apiUpdateChatItem(T.ChatType.Group, groupId, itemId, {type: "text", text: offText}, false) +function turnOff(chat: api.ChatApi, groupId: number, itemId: number, symbolKeys: boolean): void { + sessions.set(groupId, {itemId, symbolKeys}) + chat.apiUpdateChatItem(T.ChatType.Group, groupId, itemId, {type: "text", text: calculatorText(undefined, symbolKeys)}, false) .catch(e => console.log("error turning calculator off", e)) } -function tapCommand(update: (calc: Calc) => [Calc, string?]) { +async function updateCalculator(chat: api.ChatApi, {groupId, member}: Sender, update: Update): Promise { + const [calc, logLine] = update(currentCalc(groupId)) + if (logLine) await chat.apiSendTextMessage([T.ChatType.Group, groupId], logLine) + await showCalculator(chat, groupId, member, calc) +} + +function tapCommand(update: Update) { return async (ci: T.AChatItem, _command: util.BotCommand, chat: api.ChatApi): Promise => { const sender = groupSender(ci) if (!sender) return await chat.apiDeleteMemberChatItem(sender.groupId, [ci.chatItem.meta.itemId]) - const [calc, logLine] = update(currentCalc(sender.groupId)) - if (logLine) await chat.apiSendTextMessage([T.ChatType.Group, sender.groupId], logLine) - await showCalculator(chat, sender.groupId, sender.member, calc) + await updateCalculator(chat, sender, update) } } -const keyCommands = Object.fromEntries( - keypad.flat().flatMap(key => [key, keyWord(key)].map(keyword => [keyword, tapCommand(calc => press(calc, key))])) -) +const keyCommands = Object.fromEntries([...keyNames].map(([name, key]) => [name, tapCommand(calc => press(calc, key))])) async function onMessage(ci: T.AChatItem, content: T.MsgContent, chat: api.ChatApi): Promise { const sender = groupSender(ci) if (!sender || content.type !== "text") return - const keys = textKeys(content.text) - if (keys) await showCalculator(chat, sender.groupId, sender.member, evaluate(keys)) + const update = textInput(content.text) + if (update) await updateCalculator(chat, sender, update) else await chat.apiSendTextReply(ci, hint) } export function runCalculatorBot(dbOpts: bot.BotDbOpts): Promise<[api.ChatApi, T.User, T.UserContactLink | undefined]> { return bot.run({ - profile: {displayName: "SimpleX Calculator", fullName: "", preferences: {fullDelete: {allow: T.FeatureAllowed.Yes}}}, + profile: {displayName: "SimpleX Calculator", fullName: "", image: calculatorIcon, preferences: {fullDelete: {allow: T.FeatureAllowed.Yes}}}, dbOpts, options: { addressSettings: {businessAddress: true, welcomeMessage},