diff --git a/docs/task-mode.md b/docs/task-mode.md index 506f272d..6265323e 100644 --- a/docs/task-mode.md +++ b/docs/task-mode.md @@ -753,7 +753,14 @@ Besides the editor, the workspace provides a calculator, so arithmetic never needs another tab or app. It evaluates one expression with `+ - * / // %`, `^` for powers, `pi`, `e`, and the functions `sin cos tan asin acos atan atan2 sinh cosh tanh exp ln log log2 sqrt cbrt pow hypot min max abs floor -ceil round deg rad`. A selector sets whether trigonometric functions take and +ceil round deg rad`. `//` rounds down and `%` takes the sign of the divisor, so +`-7 // 3` is -3, `-7 % 3` is 2, and `a == (a // b) * b + a % b` holds for +integers of every sign while the arithmetic stays exact. An infinite divisor +is the exception: `%` leaves a finite dividend unchanged, so `-1 % 10^400` is +-1. A fractional operand can miss the identity, because `//` rounds the quotient +before flooring it, so `1 // 0.1` is 10. A dividend of the other sign that is +tiny beside the divisor rounds to the divisor itself, so `-1e-20 % 3` is 3, as +with Python floats. A selector sets whether trigonometric functions take and return radians or degrees, and in degrees the multiples of 90 are exact. A result shows fifteen significant digits, except an integer up to 2^53, which is shown whole. A diff --git a/tests/browser/calculator.test.js b/tests/browser/calculator.test.js index 74c02830..fc08aae7 100644 --- a/tests/browser/calculator.test.js +++ b/tests/browser/calculator.test.js @@ -25,6 +25,68 @@ test("arithmetic follows ordinary precedence and associativity", () => { assert.equal(evaluate(text), value, text); }); +test("% is the remainder of the floored division // performs", () => { + for (const [text, value] of [ + ["-7 // 3", -3], + ["-7 % 3", 2], + ["7 // -3", -3], + ["7 % -3", -2], + ["-7 // -3", 2], + ["-7 % -3", -1], + ["-7.5 % 2", 0.5], + ["(-7 // 3) * 3 + (-7 % 3)", -7], + ]) + assert.equal(evaluate(text), value, text); + for (const a of [-7, -6, -1, 0, 1, 6, 7]) + for (const b of [-3, -2, 2, 3]) { + const text = `(${a} // ${b}) * ${b} + (${a} % ${b})`; + assert.equal(evaluate(text), a, text); + } +}); + +test("an exact multiple leaves no negative zero for atan2 to read", () => { + assert.equal(evaluate("-6 % 3"), 0); + assert.equal(evaluate("-6 % -3"), 0); + assert.equal(evaluate("atan2(-6 % 3, -1)"), Math.PI); +}); + +test("% keeps a finite result under an infinite divisor", () => { + for (const [text, value] of [ + ["-1 % 10^400", -1], + ["1 % -1e999", 1], + ["5 % 10^400", 5], + ["-5 % -1e999", -5], + ]) + assert.equal(evaluate(text), value, text); +}); + +test("a tiny remainder of the other sign rounds to the divisor", () => { + for (const [text, value] of [ + ["-1e-20 % 3", 3], + ["1e-20 % -3", -3], + ]) + assert.equal(evaluate(text), value, text); +}); + +test("% leaves a non-negative dividend and divisor alone", () => { + for (const [text, value] of [ + ["7 % 3", 1], + ["6 % 3", 0], + ["0.1 % 1", 0.1], + ["7.5 % 2", 1.5], + ]) + assert.equal(evaluate(text), value, text); +}); + +test("% still refuses a zero divisor", () => { + for (const text of ["5 % 0", "-5 % 0", "0 % 0"]) + assert.throws( + () => evaluate(text), + { message: "The result is not a finite number" }, + text, + ); +}); + test("bad input names its problem and never runs code", () => { for (const [text, message] of [ ["", "Enter an expression"], diff --git a/web/calculator.js b/web/calculator.js index 2cc111ec..aec900de 100644 --- a/web/calculator.js +++ b/web/calculator.js @@ -8,6 +8,22 @@ export const MAX_EXPRESSION_LENGTH = 200; const RADIANS_PER_DEGREE = Math.PI / 180; +/// The remainder of the floored division `//` performs, so the result takes the +/// divisor's sign and `a == (a // b) * b + a % b` for integers. JavaScript's +/// `%` truncates instead. It adds the divisor only when the signs differ, +/// because `((a % b) + b) % b` would disturb `0.1 % 1`. A fractional operand +/// can miss the identity, because `//` rounds the quotient before flooring it. +/// An exact multiple gives 0, because `atan2` reads the sign of -0. An infinite +/// divisor keeps the plain remainder, so a finite result stays finite. A +/// dividend of the other sign that is tiny beside the divisor rounds to the +/// divisor itself: `-1e-20 % 3` is 3, as with Python floats. +function flooredRemainder(dividend, divisor) { + const remainder = dividend % divisor; + if (remainder === 0) return 0; + if (!Number.isFinite(divisor)) return remainder; + return remainder < 0 !== divisor < 0 ? remainder + divisor : remainder; +} + /// A trigonometric function of an angle in the mode's unit. In degrees the /// multiples of 90 are exact, so `cos(90)` is 0 and `tan(90)` is refused, /// where radians would give 6e-17 and 1.6e16. @@ -128,7 +144,7 @@ export function evaluate(text, { angle = "rad" } = {}) { if (take("*")) value *= unary(); else if (take("//")) value = Math.floor(value / unary()); else if (take("/")) value /= unary(); - else if (take("%")) value %= unary(); + else if (take("%")) value = flooredRemainder(value, unary()); else return value; } }