This commit is contained in:
Evgeny @ SimpleX Chat
2026-09-25 11:28:34 +00:00
parent 57aea5963f
commit 4fd204fc06
4 changed files with 40 additions and 34 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ Each user who connects via the bot's business address receives a calculator mess
- 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 and keys like `+`, `=`, `add` or `c`. Expressions like `(2 + 3) × 4` are entered as one number, and the bot replies to them with the result.
- The bot deletes keys and numbers, tapped or sent, and sends each calculation after `=`.
- The bot deletes keys and numbers, tapped or sent, and logs each computed operation, such as `15 + 10 = 25`.
- 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.
@@ -27,8 +27,8 @@ describe("keys", () => {
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 = 14"])
expect(tap("2 × 3 + 4 =").logLines).toEqual(["2 × 3 + 4 = 10"])
expect(tap("2 + 3 × 4 =").logLines).toEqual(["3 × 4 = 12\n2 + 12 = 14"])
expect(tap("2 × 3 + 4 =").logLines).toEqual(["2 × 3 = 6", "6 + 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")
@@ -41,15 +41,17 @@ describe("keys", () => {
expect(display("2 + 3 × 4 +")).toBe("14")
})
test("log only after equals", () => {
test("log each computed operation", () => {
expect(tap("1 5 + 1 0 + 5 + 1 =").logLines).toEqual(["15 + 10 = 25", "25 + 5 = 30", "30 + 1 = 31"])
expect(tap("2 + 3 × 4 × 5 +").logLines).toEqual(["3 × 4 = 12", "12 × 5 = 60\n2 + 60 = 62"])
expect(tap("2 + 3 × 4").logLines).toEqual([])
expect(tap("5 =").logLines).toEqual([])
})
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"])
expect(tap("2 + 3 × + 4 =").logLines).toEqual(["2 + 3 = 5", "5 + 4 = 9"])
expect(tap("2 × 3 + × 4 =").logLines).toEqual(["2 × 3 = 6", "6 × 4 = 24"])
})
test("continue from result", () => {
@@ -68,7 +70,7 @@ describe("keys", () => {
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(tap("1 0 0 + 2 × 1 5 % =").logLines).toEqual(["2 × 15% = 0.3\n100 + 0.3 = 100.3"])
expect(display("2 × 3 + 1 0 %")).toBe("0.6")
})
@@ -113,7 +115,7 @@ describe("typed messages", () => {
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("5", "*", "5", "/", "2", "=").logLines).toEqual(["5 × 5 = 25", "25 ÷ 2 = 12.5"])
expect(type("25", "+", "3", "c", "4", "=").logLines).toEqual(["25 + 4 = 29"])
})
@@ -59,7 +59,13 @@ test("calculator in business chat", async () => {
}
await send(["/2", "/+", "/2", "/="], receives(hasText("2 + 2 = 4")), receives(calculatorShows("4")), deletes("/+"))
await send(["2 × (3 + 4) - 1"], receives(repliesTo("2 × (3 + 4) - 1", "13")), receives(calculatorShows("13")))
await send(["25", "+", "25", "="], receives(hasText("25 + 25 = 50")), receives(calculatorShows("50")), deletes("+"))
await send(
["15", "+", "10", "+", "5", "="],
receives(hasText("15 + 10 = 25")),
receives(hasText("25 + 5 = 30")),
receives(calculatorShows("30")),
deletes("+"),
)
} finally {
await alice.close()
await calculator.close()
+23 -25
View File
@@ -10,9 +10,12 @@ export interface Calc {
mode: "typing" | "result" | "operator"
}
interface Term {
operand: string
interface Operand {
text: string
value: number
}
interface Term extends Operand {
op: Operator
}
@@ -59,7 +62,7 @@ export function press(calc: Calc, key: Key): [Calc, string?] {
switch (key) {
case "C": return [clear(calc)]
case "=": return equals(calc)
case "+": case "-": case "×": case "÷": return [operator(calc, key)]
case "+": case "-": case "×": case "÷": return operator(calc, key)
case "%": return [showNumber(calc, percent(calc), `${calc.operand}%`)]
case "√": return [showNumber(calc, Math.sqrt(value(calc)), `√${calc.operand}`)]
case "±": return [negate(calc)]
@@ -92,7 +95,7 @@ function negate(calc: Calc): Calc {
function percent(calc: Calc): number {
const last = calc.terms.at(-1)
const share = value(calc) / 100
return last && isAdditive(last.op) ? leftOperand(calc.terms) * share : share
return last && isAdditive(last.op) ? last.value * share : share
}
function showNumber(calc: Calc, n: number, operand?: string): Calc {
@@ -100,35 +103,30 @@ function showNumber(calc: Calc, n: number, operand?: string): Calc {
return {...calc, display, operand: operand ?? display, mode: "result"}
}
function operator(calc: Calc, op: Operator): Calc {
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 operator(calc: Calc, op: Operator): [Calc, string?] {
const terms = calc.mode === "operator" ? calc.terms.slice(0, -1) : calc.terms
const reduced = reduce(terms, {text: calc.operand, value: value(calc)}, precedence(op))
const display = format(reduced.right.value)
const logLine = reduced.lines.length > 0 ? reduced.lines.join("\n") : undefined
return [{display, operand: display, terms: [...reduced.terms, {...reduced.right, op}], mode: "operator"}, logLine]
}
function equals(calc: Calc): [Calc, string?] {
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]
const {right, lines} = reduce(calc.terms, {text: calc.operand, value: value(calc)}, 0)
return [{display: right.text, operand: right.text, terms: [], mode: "result"}, lines.join("\n")]
}
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 reduce(terms: Term[], right: Operand, minPrecedence: number): {terms: Term[], right: Operand, lines: string[]} {
const left = terms.at(-1)
if (!left || precedence(left.op) < minPrecedence) return {terms, right, lines: []}
const text = format(apply(left.value, left.op, right.value))
const reduced = reduce(terms.slice(0, -1), {text, value: Number(text)}, minPrecedence)
return {...reduced, lines: [`${left.text} ${left.op} ${right.text} = ${text}`, ...reduced.lines]}
}
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 precedence(op: Operator): number {
return isAdditive(op) ? 1 : 2
}
function isAdditive(op: Operator): boolean {