diff --git a/apps/simplex-calculator-bot/README.md b/apps/simplex-calculator-bot/README.md index 72a47f87ca..9646008e04 100644 --- a/apps/simplex-calculator-bot/README.md +++ b/apps/simplex-calculator-bot/README.md @@ -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. diff --git a/apps/simplex-calculator-bot/calculator.test.ts b/apps/simplex-calculator-bot/calculator.test.ts index 54083d1f9f..e7662666c4 100644 --- a/apps/simplex-calculator-bot/calculator.test.ts +++ b/apps/simplex-calculator-bot/calculator.test.ts @@ -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"]) }) diff --git a/apps/simplex-calculator-bot/calculatorBot.test.ts b/apps/simplex-calculator-bot/calculatorBot.test.ts index c2afc83433..85a40c0550 100644 --- a/apps/simplex-calculator-bot/calculatorBot.test.ts +++ b/apps/simplex-calculator-bot/calculatorBot.test.ts @@ -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() diff --git a/apps/simplex-calculator-bot/src/calculator.ts b/apps/simplex-calculator-bot/src/calculator.ts index a2b8f23763..12e33159f3 100644 --- a/apps/simplex-calculator-bot/src/calculator.ts +++ b/apps/simplex-calculator-bot/src/calculator.ts @@ -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 {