@rinxun/numeric-calculator 1.0.7 → 1.0.9-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -142,7 +142,7 @@ const result = new NC(-1000).divide(100, '20', 2).toPrecision(); // -1000/100/20
142
142
 
143
143
 
144
144
 
145
- <h4>inst.round(fractionDigits?: number)</h4>
145
+ <h4>inst.round(fractionDigits?: number, options?: { snap?: boolean })</h4>
146
146
 
147
147
  ``` typescript
148
148
  const result = new NC().round(3); // 0
@@ -151,6 +151,15 @@ const result = new NC(7.65).times(16.5).round(2); // => 7.65 * 16.5 = 126.225
151
151
  const result = new NC(26.05).times(16.5).round(2); // => 26.05 * 16.5 = 429.825 ≈ 429.83
152
152
  ```
153
153
 
154
+ `options.snap` (default `false`) makes rounding tolerant to binary floating-point representation dust. After plain float arithmetic upstream, a true `95.015` can arrive as `95.01499999999942`, and exact-decimal rounding of the *represented* value then rounds down, disagreeing with the true value. With `snap`, the amplified value is nudged by a few ULPs relative to its own magnitude before `Math.round`, so only values sitting exactly on a rounding boundary are affected — everything beyond a few ULPs of the boundary keeps its exact-decimal result, and there is no magic number to tune:
155
+
156
+ ``` typescript
157
+ const dust = 2959.9649999999997; // true value 2959.965, distorted by float subtraction upstream
158
+ const result = new NC(dust).round(2); // 2959.96 — exact decimal of the represented value
159
+ const result = new NC(dust).round(2, { snap: true }); // 2959.97 — matches the true half-up value
160
+ const result = new NC(719.6446).round(2, { snap: true }); // 719.64 — values beyond a few ULPs of the boundary are untouched
161
+ ```
162
+
154
163
 
155
164
 
156
165
  <h4>inst.toPrecision(precision?: number)</h4>
@@ -187,4 +196,4 @@ const result = new NC(10).plus(new NC().times(2, 4), '6').divide(new NC(12).minu
187
196
 
188
197
  <h2>Changelog</h2>
189
198
 
190
- If you have recently updated, please read the [changelog](https://github.com/rinxun/numeric-calculator/releases) for details of what has changed.
199
+ If you have recently updated, please read the [changelog](https://github.com/rinxun/numeric-calculator/releases) for details of what has changed.
package/index.d.ts CHANGED
@@ -3,6 +3,17 @@ declare type IConfig = {
3
3
  fractionDigits?: number;
4
4
  enableCheckBoundary?: boolean;
5
5
  };
6
+ declare type IRoundOptions = {
7
+ /**
8
+ * @description when true, nudges the amplified value by a few ULPs relative
9
+ * to its own magnitude before rounding, so that binary floating-point
10
+ * representation dust does not flip values sitting exactly on a rounding
11
+ * boundary. Use it when the input may come from plain float arithmetic
12
+ * upstream, e.g. a true 95.015 arriving as 95.01499999999942. Defaults to
13
+ * false, which keeps the exact-decimal behavior of the represented value.
14
+ */
15
+ snap?: boolean;
16
+ };
6
17
  declare type IOperand = Calculator | number | string;
7
18
  /**
8
19
  * @description Calculate addition, subtaction, multiplication, division precisely, for tackling the precision issue
@@ -134,8 +145,9 @@ declare class Calculator {
134
145
  /**
135
146
  *
136
147
  * @param {number} fractionDigits default to 2, should be in the range 0 - 20
148
+ * @param {IRoundOptions} options rounding options, see {@link IRoundOptions.snap}
137
149
  * @returns {number} rounded result
138
150
  */
139
- round(fractionDigits?: number): number;
151
+ round(fractionDigits?: number, options?: IRoundOptions): number;
140
152
  }
141
153
  export default Calculator;
package/index.js CHANGED
@@ -323,13 +323,24 @@ var Calculator = /** @class */ (function () {
323
323
  /**
324
324
  *
325
325
  * @param {number} fractionDigits default to 2, should be in the range 0 - 20
326
+ * @param {IRoundOptions} options rounding options, see {@link IRoundOptions.snap}
326
327
  * @returns {number} rounded result
327
328
  */
328
- Calculator.prototype.round = function (fractionDigits) {
329
+ Calculator.prototype.round = function (fractionDigits, options) {
329
330
  var magnification = Math.pow(10, fractionDigits || this._fractionDigits || 2);
330
331
  var amplifyingResult = this.processTimes(this._VALUE || 0, magnification);
331
332
  this.checkBoundary(amplifyingResult);
332
- return this.processDivide(Math.round(amplifyingResult), magnification);
333
+ // Round the magnitude, then restore the sign, so a negative value rounds as
334
+ // the mirror of its positive (-0.125 -> -0.13, like 0.125 -> 0.13) and the
335
+ // snap nudge moves it away from zero just as it does for positives.
336
+ var magnitude = Math.abs(amplifyingResult);
337
+ // Dust observed in real pipelines reaches a few dozen ULPs (each float op
338
+ // distorts by at most 0.5 ULP), so 64x headroom. This stays safely below
339
+ // half an amplified unit at any magnitude (it scales with the value), and
340
+ // assumes inputs are meaningful well beyond ULP precision.
341
+ var tolerance = (options === null || options === void 0 ? void 0 : options.snap) ? magnitude * Number.EPSILON * 64 : 0;
342
+ var rounded = Math.round(magnitude + tolerance);
343
+ return this.processDivide(amplifyingResult < 0 ? -rounded : rounded, magnification);
333
344
  };
334
345
  return Calculator;
335
346
  }());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rinxun/numeric-calculator",
3
- "version": "1.0.7",
3
+ "version": "1.0.9-beta.1",
4
4
  "description": "Calculate addition, subtraction, multiplication, division precisely, for tackling the precision issue",
5
5
  "keywords": [
6
6
  "long",
@@ -37,7 +37,8 @@
37
37
  "url": "https://github.com/JandneMa"
38
38
  },
39
39
  "scripts": {
40
- "build": "./node_modules/.bin/tsc"
40
+ "build": "./node_modules/.bin/tsc",
41
+ "test": "./node_modules/.bin/tsc > /dev/null && node --test tests/*.test.js"
41
42
  },
42
43
  "files": [
43
44
  "*.js",
@@ -0,0 +1,397 @@
1
+ /**
2
+ * Tests for plus, minus, times, divide, chaining, operand types, toFixed and
3
+ * toPrecision.
4
+ *
5
+ * Every expected value comes from exact decimal arithmetic on BigInt (see
6
+ * exact-decimal.js), never from the library itself. The library documents its
7
+ * results as precise to 15 significant digits (toPrecision() defaults to 15),
8
+ * so the theoretical result of an operation is the exact result rounded to 15
9
+ * significant digits, stored as the nearest double.
10
+ *
11
+ * Some 1.0.x limitations are kept as todo tests: they run and report on every
12
+ * run without failing it. Each todo names the cause.
13
+ *
14
+ * Run with `yarn test` (builds index.js first).
15
+ */
16
+ const test = require("node:test");
17
+ const assert = require("node:assert/strict");
18
+ const Calculator = require("../index.js").default;
19
+ const {
20
+ parseDecimal,
21
+ addDecimal,
22
+ subtractDecimal,
23
+ multiplyDecimal,
24
+ roundHalfAwayFromZero,
25
+ roundSignificant,
26
+ significantCandidates,
27
+ divideDecimal,
28
+ makeRandom,
29
+ randomDecimal,
30
+ expectAll,
31
+ } = require("./exact-decimal");
32
+
33
+ const PRECISION = 15;
34
+ /**
35
+ * Theoretical public result of an exact decimal: rounded to 15 significant
36
+ * digits, as a double. Within one double step of a 15-digit tie either
37
+ * neighbour is accepted (see significantCandidates).
38
+ */
39
+ const expected15 = (dec) => significantCandidates(dec, PRECISION).map(Number);
40
+ /** Theoretical public result of the exact quotient a / b. */
41
+ const expectedQuotient = (a, b) =>
42
+ significantCandidates(divideDecimal(parseDecimal(a), parseDecimal(b), PRECISION), PRECISION).map(Number);
43
+
44
+ /** Random operand: below 10^7 with 0-4 decimals, so sums stay within 12 digits. */
45
+ const operand = (random, allowNegative = true) =>
46
+ randomDecimal(random, 10000000, Math.floor(random() * 5), allowNegative);
47
+
48
+ /** Random money amount: below 10^7 with 2 decimals. */
49
+ const money = (random, allowNegative = true) =>
50
+ randomDecimal(random, 10000000, 2, allowNegative);
51
+
52
+ /** n random pairs from `make`. */
53
+ function* pairs(seed, n, make) {
54
+ const random = makeRandom(seed);
55
+ for (let i = 0; i < n; i++) yield { a: make(random), b: make(random) };
56
+ }
57
+
58
+ const N = 100000;
59
+
60
+ /**
61
+ * Known 1.0.x limitations, used as `todo` reasons. Each is fixed by scaling
62
+ * operands to exact integers (Math.round) in processTimes/processDivide and by
63
+ * dividing once in processDivide; drop the todo when that lands.
64
+ */
65
+ const KNOWN = {
66
+ scaling:
67
+ "1.0.x scales operands with a float multiply (4486513.64 * 100 = 448651363.99999994), " +
68
+ "so about 0.5-1.5% of results are off in the last digits",
69
+ divide:
70
+ "1.0.x rounds the quotient to 15 digits, scales it, and the caller rounds again, " +
71
+ "so a few quotients are off by one in the 15th digit",
72
+ };
73
+
74
+ // #region plus / minus
75
+ test("plus: a + b equals the exact sum, operands of the same sign", () => {
76
+ const random = makeRandom(11);
77
+ function* cases() {
78
+ for (let i = 0; i < N; i++) {
79
+ const sign = random() < 0.5 ? "-" : "";
80
+ yield { a: sign + operand(random, false), b: sign + operand(random, false) };
81
+ }
82
+ }
83
+ expectAll(cases(), ({ a, b }) => ({
84
+ actual: new Calculator(Number(a)).plus(Number(b)).toPrecision(),
85
+ expected: expected15(addDecimal(parseDecimal(a), parseDecimal(b))),
86
+ }));
87
+ });
88
+
89
+ test("plus: a + b equals the exact sum, operands of any sign", { todo: KNOWN.scaling }, () => {
90
+ expectAll(pairs(12, N, operand), ({ a, b }) => ({
91
+ actual: new Calculator(Number(a)).plus(Number(b)).toPrecision(),
92
+ expected: expected15(addDecimal(parseDecimal(a), parseDecimal(b))),
93
+ }));
94
+ });
95
+
96
+ test("minus: a - b equals the exact difference", { todo: KNOWN.scaling }, () => {
97
+ expectAll(pairs(13, N, operand), ({ a, b }) => ({
98
+ actual: new Calculator(Number(a)).minus(Number(b)).toPrecision(),
99
+ expected: expected15(subtractDecimal(parseDecimal(a), parseDecimal(b))),
100
+ }));
101
+ });
102
+
103
+ test("minus: money a - b equals the exact difference", { todo: KNOWN.scaling }, () => {
104
+ expectAll(pairs(14, N, money), ({ a, b }) => ({
105
+ actual: new Calculator(Number(a)).minus(Number(b)).toPrecision(),
106
+ expected: expected15(subtractDecimal(parseDecimal(a), parseDecimal(b))),
107
+ }));
108
+ });
109
+
110
+ test("plus/minus on money, then toFixed(2), equal the exact result", () => {
111
+ expectAll(pairs(15, N, money), ({ a, b }) => {
112
+ const exact = subtractDecimal(parseDecimal(a), parseDecimal(b));
113
+ return {
114
+ actual: new Calculator(Number(a)).minus(Number(b)).toFixed(2),
115
+ expected: roundHalfAwayFromZero(exact, 2).replace(/^-(0\.00)$/, "$1"),
116
+ };
117
+ });
118
+ });
119
+
120
+ test("plus: several operands at once equal the exact sum", { todo: KNOWN.scaling }, () => {
121
+ const random = makeRandom(16);
122
+ function* cases() {
123
+ for (let i = 0; i < N / 4; i++) {
124
+ yield { ops: Array.from({ length: 2 + Math.floor(random() * 5) }, () => money(random)) };
125
+ }
126
+ }
127
+ expectAll(cases(), ({ ops }) => ({
128
+ actual: new Calculator(Number(ops[0])).plus(...ops.slice(1).map(Number)).toPrecision(),
129
+ expected: expected15(ops.map(parseDecimal).reduce(addDecimal)),
130
+ }));
131
+ });
132
+
133
+ test("minus: x - x is 0 and x - 0 is x", () => {
134
+ const random = makeRandom(17);
135
+ for (let i = 0; i < 10000; i++) {
136
+ const x = Number(operand(random));
137
+ assert.equal(new Calculator(x).minus(x).toPrecision(), 0, `${x} - ${x}`);
138
+ assert.equal(new Calculator(x).minus(0).toPrecision(), x, `${x} - 0`);
139
+ assert.equal(new Calculator(x).plus(0).toPrecision(), x, `${x} + 0`);
140
+ }
141
+ });
142
+
143
+ test("plus is commutative: a + b equals b + a", () => {
144
+ expectAll(pairs(18, N, operand), ({ a, b }) => ({
145
+ actual: new Calculator(Number(a)).plus(Number(b)).toPrecision(),
146
+ expected: new Calculator(Number(b)).plus(Number(a)).toPrecision(),
147
+ }));
148
+ });
149
+ // #endregion
150
+
151
+ // #region times
152
+ /**
153
+ * The library multiplies the operands as integers (1.25 * 0.5 -> 125 * 5), so
154
+ * results are exact only while that integer product stays a safe integer.
155
+ * Beyond it, enableCheckBoundary warns (tested below).
156
+ */
157
+ const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER);
158
+ const productIsSafe = (a, b) => {
159
+ const { unscaled } = multiplyDecimal(parseDecimal(a), parseDecimal(b));
160
+ return (unscaled < 0n ? -unscaled : unscaled) <= MAX_SAFE;
161
+ };
162
+
163
+ test("times: a * b equals the exact product, within the safe-integer range", { todo: KNOWN.scaling }, () => {
164
+ function* cases() {
165
+ for (const pair of pairs(21, N, operand)) if (productIsSafe(pair.a, pair.b)) yield pair;
166
+ }
167
+ expectAll(cases(), ({ a, b }) => ({
168
+ actual: new Calculator(Number(a)).times(Number(b)).toPrecision(),
169
+ expected: expected15(multiplyDecimal(parseDecimal(a), parseDecimal(b))),
170
+ }));
171
+ });
172
+
173
+ test("enableCheckBoundary warns when the integer product leaves the safe range", (t) => {
174
+ const warn = t.mock.method(console, "warn", () => {});
175
+ new Calculator(4408410.0051, { enableCheckBoundary: true }).times(-8862149.1585);
176
+ assert.ok(warn.mock.callCount() > 0, "expected a boundary warning");
177
+ warn.mock.resetCalls();
178
+ new Calculator(1.25, { enableCheckBoundary: true }).times(0.5);
179
+ assert.equal(warn.mock.callCount(), 0, "no warning for a small product");
180
+ });
181
+
182
+ test("times: money * rate equals the exact product", () => {
183
+ const random = makeRandom(22);
184
+ function* cases() {
185
+ for (let i = 0; i < N; i++) {
186
+ yield { a: money(random), r: randomDecimal(random, 100, 4) };
187
+ }
188
+ }
189
+ expectAll(cases(), ({ a, r }) => ({
190
+ actual: new Calculator(Number(a)).times(Number(r)).toPrecision(),
191
+ expected: expected15(multiplyDecimal(parseDecimal(a), parseDecimal(r))),
192
+ }));
193
+ });
194
+
195
+ test("times: small numbers written in exponent form", () => {
196
+ const random = makeRandom(23);
197
+ function* cases() {
198
+ for (let i = 0; i < N / 4; i++) {
199
+ // e.g. 1.234e-9 * 5.6e-7: mantissas with up to 4 digits, exponents -12..-5
200
+ const a = `${(1 + Math.floor(random() * 9999)) / 1000}e-${5 + Math.floor(random() * 8)}`;
201
+ const b = `${(1 + Math.floor(random() * 9999)) / 1000}e-${5 + Math.floor(random() * 8)}`;
202
+ yield { a, b };
203
+ }
204
+ }
205
+ const toDec = (str) => {
206
+ const [mantissa, exponent] = str.split("e");
207
+ const dec = parseDecimal(mantissa);
208
+ return { unscaled: dec.unscaled, scale: dec.scale - Number(exponent) };
209
+ };
210
+ expectAll(cases(), ({ a, b }) => ({
211
+ actual: new Calculator(Number(a)).times(Number(b)).toPrecision(),
212
+ expected: expected15(multiplyDecimal(toDec(a), toDec(b))),
213
+ }));
214
+ });
215
+
216
+ test("times is commutative and has 1 as identity", () => {
217
+ expectAll(pairs(24, N, operand), ({ a, b }) => ({
218
+ actual: new Calculator(Number(a)).times(Number(b)).toPrecision(),
219
+ expected: new Calculator(Number(b)).times(Number(a)).toPrecision(),
220
+ }));
221
+ const random = makeRandom(25);
222
+ for (let i = 0; i < 10000; i++) {
223
+ const x = Number(operand(random));
224
+ assert.equal(new Calculator(x).times(1).toPrecision(), x, `${x} * 1`);
225
+ }
226
+ });
227
+ // #endregion
228
+
229
+ // #region divide
230
+ test("divide: a / b equals the exact quotient rounded to 15 significant digits", { todo: KNOWN.divide }, () => {
231
+ const random = makeRandom(31);
232
+ function* cases() {
233
+ for (let i = 0; i < N; i++) {
234
+ const a = operand(random);
235
+ let b = operand(random);
236
+ if (Number(b) === 0) b = "1";
237
+ yield { a, b };
238
+ }
239
+ }
240
+ expectAll(cases(), ({ a, b }) => ({
241
+ actual: new Calculator(Number(a)).divide(Number(b)).toPrecision(),
242
+ expected: expectedQuotient(a, b),
243
+ }));
244
+ });
245
+
246
+ test("divide: money / pay periods equals the exact quotient", () => {
247
+ const random = makeRandom(32);
248
+ const periods = ["1", "12", "24", "26", "52"];
249
+ function* cases() {
250
+ for (let i = 0; i < N; i++) {
251
+ yield { a: money(random), p: periods[Math.floor(random() * periods.length)] };
252
+ }
253
+ }
254
+ expectAll(cases(), ({ a, p }) => ({
255
+ actual: new Calculator(Number(a)).divide(Number(p)).toPrecision(),
256
+ expected: expectedQuotient(a, p),
257
+ }));
258
+ });
259
+
260
+ test("divide: (a * b) / b gives back a when the product is exact", () => {
261
+ const random = makeRandom(33);
262
+ function* cases() {
263
+ for (let i = 0; i < N; i++) {
264
+ const a = money(random);
265
+ let b = randomDecimal(random, 1000, 2);
266
+ if (Number(b) === 0) b = "1.25";
267
+ const product = roundHalfAwayFromZero(multiplyDecimal(parseDecimal(a), parseDecimal(b)), 4);
268
+ yield { a, b, product };
269
+ }
270
+ }
271
+ expectAll(cases(), ({ a, b, product }) => ({
272
+ actual: new Calculator(Number(product)).divide(Number(b)).toPrecision(),
273
+ expected: Number(a),
274
+ }));
275
+ });
276
+ // #endregion
277
+
278
+ // #region chaining and operands
279
+ test("chained plus/minus/times equal the exact result", { todo: KNOWN.scaling }, () => {
280
+ const random = makeRandom(41);
281
+ function* cases() {
282
+ for (let i = 0; i < N / 2; i++) {
283
+ // (a + b) * r - c, all money except a 2-decimal rate below 10
284
+ yield { a: money(random), b: money(random), r: randomDecimal(random, 10, 2), c: money(random) };
285
+ }
286
+ }
287
+ expectAll(cases(), ({ a, b, r, c }) => {
288
+ const exact = subtractDecimal(
289
+ multiplyDecimal(addDecimal(parseDecimal(a), parseDecimal(b)), parseDecimal(r)),
290
+ parseDecimal(c)
291
+ );
292
+ return {
293
+ actual: new Calculator(Number(a)).plus(Number(b)).times(Number(r)).minus(Number(c)).toPrecision(),
294
+ expected: expected15(exact),
295
+ };
296
+ });
297
+ });
298
+
299
+ test("string, number and Calculator operands give the same result", () => {
300
+ const random = makeRandom(42);
301
+ for (let i = 0; i < 10000; i++) {
302
+ const a = operand(random);
303
+ const b = operand(random);
304
+ const fromNumbers = new Calculator(Number(a)).plus(Number(b)).toPrecision();
305
+ assert.equal(new Calculator(a).plus(b).toPrecision(), fromNumbers, `"${a}" + "${b}"`);
306
+ assert.equal(
307
+ new Calculator(new Calculator(a)).plus(new Calculator(b)).toPrecision(),
308
+ fromNumbers,
309
+ `Calculator(${a}) + Calculator(${b})`
310
+ );
311
+ }
312
+ });
313
+
314
+ test("the first operand can come from the operation when the constructor has none", () => {
315
+ assert.equal(new Calculator().plus(0.1, 0.2).toPrecision(), 0.3);
316
+ assert.equal(new Calculator().minus(1, 0.9).toPrecision(), 0.1);
317
+ assert.equal(new Calculator().times(0.1, 3).toPrecision(), 0.3);
318
+ assert.equal(new Calculator().divide(0.3, 0.1).toPrecision(), 3);
319
+ });
320
+
321
+ test("well-known float traps give the exact decimal result", () => {
322
+ const cases = [
323
+ { op: "plus", a: "0.1", b: "0.2", exact: "0.3" },
324
+ { op: "plus", a: "0.7", b: "0.1", exact: "0.8" },
325
+ { op: "minus", a: "1", b: "0.9", exact: "0.1" },
326
+ { op: "minus", a: "0.3", b: "0.1", exact: "0.2" },
327
+ { op: "times", a: "0.1", b: "3", exact: "0.3" },
328
+ { op: "times", a: "1.005", b: "1000", exact: "1005" },
329
+ { op: "times", a: "19.9", b: "100", exact: "1990" },
330
+ { op: "divide", a: "0.3", b: "0.1", exact: "3" },
331
+ { op: "divide", a: "0.69", b: "10", exact: "0.069" },
332
+ ];
333
+ expectAll(cases, ({ op, a, b, exact }) => ({
334
+ actual: new Calculator(Number(a))[op](Number(b)).toPrecision(),
335
+ expected: Number(exact),
336
+ }));
337
+ });
338
+ // #endregion
339
+
340
+ // #region documented behaviour that is not arithmetic
341
+ test("non-numeric string operands count as 0", () => {
342
+ assert.equal(new Calculator("abc").plus(5).toPrecision(), 5);
343
+ assert.equal(new Calculator(5).plus("abc").toPrecision(), 5);
344
+ assert.equal(new Calculator(5).times("abc").toPrecision(), 0);
345
+ });
346
+
347
+ test("times by 0 and divide by 0 both give 0", () => {
348
+ assert.equal(new Calculator(12.34).times(0).toPrecision(), 0);
349
+ assert.equal(new Calculator(12.34).divide(0).toPrecision(), 0);
350
+ });
351
+ // #endregion
352
+
353
+ // #region toFixed / toPrecision
354
+ test("toFixed(n): values not on a half match exact rounding", () => {
355
+ const random = makeRandom(51);
356
+ function* cases() {
357
+ for (let i = 0; i < N; i++) {
358
+ const n = 1 + Math.floor(random() * 4);
359
+ const str = randomDecimal(random, 1000000, n + 1 + Math.floor(random() * 2));
360
+ const dec = parseDecimal(str);
361
+ const tail = dec.unscaled % 10n ** BigInt(dec.scale - n);
362
+ const half = 5n * 10n ** BigInt(dec.scale - n - 1);
363
+ if (tail === half || tail === -half) continue;
364
+ yield { str, n };
365
+ }
366
+ }
367
+ expectAll(cases(), ({ str, n }) => ({
368
+ actual: new Calculator(Number(str)).toFixed(n),
369
+ expected: roundHalfAwayFromZero(parseDecimal(str), n).replace(/^-(0\.0+)$/, "$1"),
370
+ }));
371
+ });
372
+
373
+ test("toFixed() pads to the configured fraction digits, default 2", () => {
374
+ assert.equal(new Calculator(3).toFixed(), "3.00");
375
+ assert.equal(new Calculator(3.1).toFixed(4), "3.1000");
376
+ assert.equal(new Calculator(3.1, { fractionDigits: 3 }).toFixed(), "3.100");
377
+ });
378
+
379
+ test("toPrecision(p): matches the exact value rounded to p significant digits, off a half", () => {
380
+ const random = makeRandom(52);
381
+ function* cases() {
382
+ for (let i = 0; i < N; i++) {
383
+ const p = 1 + Math.floor(random() * 12);
384
+ const str = operand(random);
385
+ const dec = parseDecimal(str);
386
+ // Skip exact halves at digit p; how they round depends on the stored double.
387
+ const digits = (dec.unscaled < 0n ? -dec.unscaled : dec.unscaled).toString();
388
+ if (digits.length > p && /^50*$/.test(digits.slice(p))) continue;
389
+ yield { str, p };
390
+ }
391
+ }
392
+ expectAll(cases(), ({ str, p }) => ({
393
+ actual: new Calculator(Number(str)).toPrecision(p),
394
+ expected: Number(roundSignificant(parseDecimal(str), p)),
395
+ }));
396
+ });
397
+ // #endregion
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Exact decimal arithmetic on BigInt, used as the theoretical reference in the
3
+ * tests. Nothing here calls the library, so expected values never come from the
4
+ * code under test.
5
+ */
6
+ const assert = require("node:assert/strict");
7
+
8
+ // #region Exact decimals
9
+ /**
10
+ * An exact decimal number: value = unscaled / 10^scale, unscaled is a signed BigInt.
11
+ * @typedef {{ unscaled: bigint, scale: number }} Decimal
12
+ */
13
+
14
+ const abs = (n) => (n < 0n ? -n : n);
15
+ const pow10 = (n) => 10n ** BigInt(n);
16
+
17
+ /** @returns {Decimal} */
18
+ function parseDecimal(str) {
19
+ const match = /^(-?)(\d+)(?:\.(\d+))?$/.exec(str);
20
+ if (!match) throw new Error(`not a plain decimal: ${str}`);
21
+ const fraction = match[3] || "";
22
+ const magnitude = BigInt(match[2] + fraction);
23
+ return { unscaled: match[1] === "-" ? -magnitude : magnitude, scale: fraction.length };
24
+ }
25
+
26
+ /** Rescale to a larger scale without changing the value. */
27
+ function rescale(dec, scale) {
28
+ return { unscaled: dec.unscaled * pow10(scale - dec.scale), scale };
29
+ }
30
+
31
+ /** @returns {Decimal} exact a + b */
32
+ function addDecimal(a, b) {
33
+ const scale = Math.max(a.scale, b.scale);
34
+ return { unscaled: rescale(a, scale).unscaled + rescale(b, scale).unscaled, scale };
35
+ }
36
+
37
+ /** @returns {Decimal} exact a - b */
38
+ function subtractDecimal(a, b) {
39
+ return addDecimal(a, { unscaled: -b.unscaled, scale: b.scale });
40
+ }
41
+
42
+ /** @returns {Decimal} exact a * b */
43
+ function multiplyDecimal(a, b) {
44
+ return { unscaled: a.unscaled * b.unscaled, scale: a.scale + b.scale };
45
+ }
46
+
47
+ /** Decimal string for unscaled / 10^scale; scale may be negative. */
48
+ function formatDecimal(unscaled, scale) {
49
+ const negative = unscaled < 0n;
50
+ let digits = abs(unscaled).toString();
51
+ if (scale <= 0) {
52
+ digits += "0".repeat(-scale);
53
+ } else {
54
+ digits = digits.padStart(scale + 1, "0");
55
+ digits = `${digits.slice(0, -scale)}.${digits.slice(-scale)}`;
56
+ }
57
+ return (negative && unscaled !== 0n ? "-" : "") + digits;
58
+ }
59
+
60
+ /** Drop `drop` trailing digits of a non-negative integer, rounding half away from zero. */
61
+ function dropDigitsHalfAway(magnitude, drop) {
62
+ if (drop <= 0) return magnitude * pow10(-drop);
63
+ const divisor = pow10(drop);
64
+ const quotient = magnitude / divisor;
65
+ return (magnitude % divisor) * 2n >= divisor ? quotient + 1n : quotient;
66
+ }
67
+
68
+ /** Round to n fraction digits, halves away from zero. Returns a decimal string. */
69
+ function roundHalfAwayFromZero(dec, n) {
70
+ const sign = dec.unscaled < 0n ? -1n : 1n;
71
+ const magnitude = dropDigitsHalfAway(abs(dec.unscaled), dec.scale - n);
72
+ return formatDecimal(sign * magnitude, n);
73
+ }
74
+
75
+ /** Round to p significant digits, halves away from zero. Returns a decimal string. */
76
+ function roundSignificant(dec, p) {
77
+ const magnitude = abs(dec.unscaled);
78
+ if (magnitude === 0n) return "0";
79
+ const drop = magnitude.toString().length - p;
80
+ if (drop <= 0) return formatDecimal(dec.unscaled, dec.scale);
81
+ const sign = dec.unscaled < 0n ? -1n : 1n;
82
+ return formatDecimal(sign * dropDigitsHalfAway(magnitude, drop), dec.scale - drop);
83
+ }
84
+
85
+ /**
86
+ * The values a double-based result may take when it claims p significant
87
+ * digits: the exact value rounded to p digits and, only when the exact value
88
+ * sits within one double step (2^-52 relative) of a p-digit tie, the other
89
+ * neighbour too. A double cannot tell which side of such a tie the exact value
90
+ * is on, so either answer is as good as the format allows.
91
+ * @returns {string[]} acceptable decimal strings, the nearest first
92
+ */
93
+ function significantCandidates(dec, p) {
94
+ const magnitude = abs(dec.unscaled);
95
+ const drop = magnitude.toString().length - p;
96
+ const nearest = roundSignificant(dec, p);
97
+ if (magnitude === 0n || drop <= 0) return [nearest];
98
+ const unit = pow10(drop);
99
+ const remainder = magnitude % unit;
100
+ const distanceToTie = abs(remainder * 2n - unit); // in half-units
101
+ if (distanceToTie * 2n ** 52n > magnitude * 2n) return [nearest];
102
+ const sign = dec.unscaled < 0n ? -1n : 1n;
103
+ const down = formatDecimal(sign * (magnitude / unit), dec.scale - drop);
104
+ const up = formatDecimal(sign * (magnitude / unit + 1n), dec.scale - drop);
105
+ return [nearest, nearest === up ? down : up];
106
+ }
107
+
108
+ /** Exact a / b with p + 3 extra digits and a sticky digit, as a Decimal. */
109
+ function divideDecimal(a, b, p) {
110
+ if (b.unscaled === 0n) throw new Error("division by zero");
111
+ const numerator = abs(a.unscaled) * pow10(b.scale);
112
+ const denominator = abs(b.unscaled) * pow10(a.scale);
113
+ const extra =
114
+ p + 3 + Math.max(0, denominator.toString().length - numerator.toString().length);
115
+ const scaled = numerator * pow10(extra);
116
+ const quotient = (scaled / denominator) * 10n + (scaled % denominator !== 0n ? 1n : 0n);
117
+ const negative = a.unscaled < 0n !== b.unscaled < 0n && a.unscaled !== 0n;
118
+ return { unscaled: negative ? -quotient : quotient, scale: extra + 1 };
119
+ }
120
+
121
+ /**
122
+ * a / b rounded to p significant digits (halves away from zero), computed from
123
+ * enough exact quotient digits plus a sticky bit, so it is correctly rounded.
124
+ */
125
+ function divideSignificant(a, b, p) {
126
+ if (b.unscaled === 0n) throw new Error("division by zero");
127
+ const numerator = abs(a.unscaled) * pow10(b.scale);
128
+ const denominator = abs(b.unscaled) * pow10(a.scale);
129
+ const extra =
130
+ p + 3 + Math.max(0, denominator.toString().length - numerator.toString().length);
131
+ const scaled = numerator * pow10(extra);
132
+ let quotient = scaled / denominator;
133
+ // A non-zero remainder only matters to break an exact-looking half; append a
134
+ // sticky digit so a truncated ...5000 that is really above half rounds up.
135
+ const sticky = scaled % denominator !== 0n;
136
+ quotient = quotient * 10n + (sticky ? 1n : 0n);
137
+ const negative = a.unscaled < 0n !== b.unscaled < 0n && a.unscaled !== 0n;
138
+ return roundSignificant({ unscaled: negative ? -quotient : quotient, scale: extra + 1 }, p);
139
+ }
140
+
141
+ /** Number of significant digits of a decimal. */
142
+ function significantDigits(dec) {
143
+ const magnitude = abs(dec.unscaled);
144
+ if (magnitude === 0n) return 1;
145
+ return magnitude.toString().replace(/0+$/, "").length || 1;
146
+ }
147
+
148
+ /** The double that stores a decimal string (the nearest double to it). */
149
+ const toNumber = (str) => Number(str);
150
+ // #endregion
151
+
152
+ // #region Test helpers
153
+ /** Seeded PRNG (mulberry32), so every run checks the same values. */
154
+ function makeRandom(seed) {
155
+ let state = seed >>> 0;
156
+ return () => {
157
+ state = (state + 0x6d2b79f5) >>> 0;
158
+ let t = state;
159
+ t = Math.imul(t ^ (t >>> 15), t | 1);
160
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
161
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
162
+ };
163
+ }
164
+
165
+ /** Random decimal string: below `maxInt` before the point, exactly `decimals` after it. */
166
+ function randomDecimal(random, maxInt, decimals, allowNegative = true) {
167
+ const intPart = Math.floor(random() * maxInt);
168
+ let fraction = "";
169
+ for (let i = 0; i < decimals; i++) fraction += Math.floor(random() * 10);
170
+ const sign = allowNegative && random() < 0.5 ? "-" : "";
171
+ return `${sign}${intPart}${decimals ? `.${fraction}` : ""}`;
172
+ }
173
+
174
+ /**
175
+ * Run `check` on every case and fail once with the first few mismatches, so a
176
+ * failure shows concrete inputs instead of only a count.
177
+ */
178
+ function expectAll(cases, check) {
179
+ const failures = [];
180
+ let total = 0;
181
+ for (const c of cases) {
182
+ total++;
183
+ // `expected` is one value, or an array of acceptable values (nearest first).
184
+ const { actual, expected } = check(c);
185
+ const accepted = Array.isArray(expected) ? expected : [expected];
186
+ if (!accepted.some((e) => Object.is(actual, e))) {
187
+ failures.push({ ...c, actual, expected: accepted.length === 1 ? accepted[0] : accepted });
188
+ }
189
+ }
190
+ assert.ok(total > 0, "no cases were generated");
191
+ assert.deepEqual(
192
+ failures.slice(0, 10),
193
+ [],
194
+ `${failures.length} of ${total} cases differ from the exact result`
195
+ );
196
+ }
197
+ // #endregion
198
+
199
+ module.exports = {
200
+ parseDecimal,
201
+ addDecimal,
202
+ subtractDecimal,
203
+ multiplyDecimal,
204
+ roundHalfAwayFromZero,
205
+ roundSignificant,
206
+ significantCandidates,
207
+ divideDecimal,
208
+ divideSignificant,
209
+ significantDigits,
210
+ toNumber,
211
+ makeRandom,
212
+ randomDecimal,
213
+ expectAll,
214
+ };
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Checks the exact-decimal reference itself against values worked out by hand,
3
+ * since every other test trusts it.
4
+ */
5
+ const test = require("node:test");
6
+ const assert = require("node:assert/strict");
7
+ const E = require("./exact-decimal");
8
+
9
+ const d = E.parseDecimal;
10
+ const sig = (str, p) => E.roundSignificant(d(str), p);
11
+
12
+ test("reference: exact add, subtract and multiply", () => {
13
+ assert.deepEqual(E.addDecimal(d("0.1"), d("0.2")), { unscaled: 3n, scale: 1 });
14
+ assert.deepEqual(E.subtractDecimal(d("1.5"), d("2.25")), { unscaled: -75n, scale: 2 });
15
+ assert.deepEqual(E.multiplyDecimal(d("1.2"), d("-0.05")), { unscaled: -60n, scale: 3 });
16
+ assert.deepEqual(E.addDecimal(d("-615403.718"), d("613501.153")), { unscaled: -1902565n, scale: 3 });
17
+ });
18
+
19
+ test("reference: rounding to fraction digits, halves away from zero", () => {
20
+ assert.equal(E.roundHalfAwayFromZero(d("1.005"), 2), "1.01");
21
+ assert.equal(E.roundHalfAwayFromZero(d("-1.005"), 2), "-1.01");
22
+ assert.equal(E.roundHalfAwayFromZero(d("1.0049"), 2), "1.00");
23
+ assert.equal(E.roundHalfAwayFromZero(d("-0.004"), 2), "0.00");
24
+ assert.equal(E.roundHalfAwayFromZero(d("2.5"), 0), "3");
25
+ assert.equal(E.roundHalfAwayFromZero(d("7"), 2), "7.00");
26
+ });
27
+
28
+ test("reference: rounding to significant digits", () => {
29
+ assert.equal(sig("123456", 3), "123000");
30
+ assert.equal(sig("0.00012345", 3), "0.000123");
31
+ assert.equal(sig("-2.5", 1), "-3");
32
+ assert.equal(Number(sig("9.9999", 3)), 10); // carries into a new digit
33
+ assert.equal(sig("0.3", 15), "0.3");
34
+ assert.equal(sig("0", 15), "0");
35
+ });
36
+
37
+ test("reference: division rounded to significant digits", () => {
38
+ const div = (a, b, p) => E.divideSignificant(d(a), d(b), p);
39
+ assert.equal(div("1", "3", 15), "0.333333333333333");
40
+ assert.equal(div("2", "3", 15), "0.666666666666667");
41
+ assert.equal(div("100", "7", 15), "14.2857142857143");
42
+ assert.equal(div("-1", "8", 2), "-0.13");
43
+ assert.equal(div("1", "-8", 2), "-0.13");
44
+ assert.equal(div("0.69", "10", 15), "0.0690000000000000");
45
+ assert.equal(div("0", "5", 15), "0");
46
+ // 1/16 = 0.0625 exactly: a true tie at 2 digits rounds away from zero.
47
+ assert.equal(div("1", "16", 3), "0.0625");
48
+ assert.equal(div("1", "16", 2), "0.063");
49
+ // 1/16.0001 is just below 0.0625, so it rounds down even though the digits start 0.0624999...
50
+ assert.equal(div("1", "16.0001", 2), "0.062");
51
+ });
52
+
53
+ test("reference: tie zone accepts both neighbours only within one double step", () => {
54
+ // 16 digits ending in 5: an exact tie at 15 digits, so both neighbours are accepted.
55
+ assert.deepEqual(E.significantCandidates(d("1234567890123.455"), 15), [
56
+ "1234567890123.46",
57
+ "1234567890123.45",
58
+ ]);
59
+ // Clearly below the tie: only the nearest.
60
+ assert.deepEqual(E.significantCandidates(d("1234567890123.454"), 15), ["1234567890123.45"]);
61
+ // Short values are exact: only themselves.
62
+ assert.deepEqual(E.significantCandidates(d("0.3"), 15), ["0.3"]);
63
+ });
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Tests for Calculator#round.
3
+ *
4
+ * Every expected value comes from exact decimal arithmetic on BigInt (see
5
+ * exact-decimal.js), never from the library itself. The
6
+ * rounding rule the reference implements is: round to `n` fraction digits,
7
+ * halves away from zero (0.125 -> 0.13, -0.125 -> -0.13).
8
+ *
9
+ * Run with `yarn test` (builds index.js first).
10
+ */
11
+ const test = require("node:test");
12
+ const assert = require("node:assert/strict");
13
+ const Calculator = require("../index.js").default;
14
+
15
+ const {
16
+ parseDecimal,
17
+ addDecimal,
18
+ multiplyDecimal,
19
+ roundHalfAwayFromZero,
20
+ significantDigits,
21
+ makeRandom,
22
+ randomDecimal,
23
+ expectAll,
24
+ } = require("./exact-decimal");
25
+
26
+ /** The double the theoretical result is stored as (the nearest double to it). */
27
+ function expectedRound(dec, n) {
28
+ return Number(roundHalfAwayFromZero(dec, n));
29
+ }
30
+
31
+ const round = (value, n, options) => new Calculator(value).round(n, options);
32
+ const SNAP = { snap: true };
33
+
34
+ test("with snap: every half cent from 0.005 to 1000.005 rounds away from zero, both signs", () => {
35
+ function* halfCents() {
36
+ for (let cents = 0; cents <= 100000; cents++) {
37
+ const str = `${(cents / 100).toFixed(2)}5`;
38
+ yield { str };
39
+ yield { str: `-${str}` };
40
+ }
41
+ }
42
+ expectAll(halfCents(), ({ str }) => ({
43
+ actual: round(Number(str), 2, SNAP),
44
+ expected: expectedRound(parseDecimal(str), 2),
45
+ }));
46
+ });
47
+
48
+ test("with snap: random decimals (up to 12 significant digits) match exact rounding", () => {
49
+ const random = makeRandom(1);
50
+ function* cases() {
51
+ for (let i = 0; i < 200000; i++) {
52
+ const n = 1 + Math.floor(random() * 4); // 1..4
53
+ const decimals = Math.floor(random() * 7); // 0..6, so some are already rounded
54
+ const str = randomDecimal(random, 1000000, Math.min(decimals, 12 - 7));
55
+ yield { str, n };
56
+ }
57
+ }
58
+ expectAll(cases(), ({ str, n }) => ({
59
+ actual: round(Number(str), n, SNAP),
60
+ expected: expectedRound(parseDecimal(str), n),
61
+ }));
62
+ });
63
+
64
+ test("with snap: float error from plain JS sums is removed (a + b rounds as the exact sum)", () => {
65
+ // Same-sign operands, so the float error stays within a few ULPs of the sum
66
+ // itself, which is what snap's allowance is sized for. See the todo test on
67
+ // cancellation below for opposite signs.
68
+ const random = makeRandom(2);
69
+ function* cases() {
70
+ for (let i = 0; i < 100000; i++) {
71
+ const a = randomDecimal(random, 1000000, 3, false);
72
+ const b = randomDecimal(random, 1000000, 3, false);
73
+ yield { a, b };
74
+ yield { a: `-${a}`, b: `-${b}` };
75
+ }
76
+ }
77
+ expectAll(cases(), ({ a, b }) => ({
78
+ actual: round(Number(a) + Number(b), 2, SNAP),
79
+ expected: expectedRound(addDecimal(parseDecimal(a), parseDecimal(b)), 2),
80
+ }));
81
+ });
82
+
83
+ test("with snap: float error from plain JS products is removed (a * b rounds as the exact product)", () => {
84
+ const random = makeRandom(3);
85
+ function* cases() {
86
+ for (let i = 0; i < 100000; i++) {
87
+ const a = randomDecimal(random, 100000, 2);
88
+ const b = randomDecimal(random, 100, 1);
89
+ yield { a, b };
90
+ }
91
+ }
92
+ expectAll(cases(), ({ a, b }) => {
93
+ const exact = multiplyDecimal(parseDecimal(a), parseDecimal(b));
94
+ assert.ok(significantDigits(exact) <= 12);
95
+ return {
96
+ actual: round(Number(a) * Number(b), 2, SNAP),
97
+ expected: expectedRound(exact, 2),
98
+ };
99
+ });
100
+ });
101
+
102
+ test("with snap: (t / m) * m in plain JS rounds as t, for half cents t", () => {
103
+ const random = makeRandom(4);
104
+ const divisors = [3, 7, 9, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47];
105
+ function* cases() {
106
+ for (let i = 0; i < 100000; i++) {
107
+ const cents = Math.floor(random() * 1000000);
108
+ const sign = random() < 0.5 ? "-" : "";
109
+ const t = `${sign}${(cents / 100).toFixed(2)}5`;
110
+ const m = divisors[Math.floor(random() * divisors.length)];
111
+ yield { t, m };
112
+ }
113
+ }
114
+ // Mathematically (t / m) * m === t; only the float evaluation is off.
115
+ expectAll(cases(), ({ t, m }) => ({
116
+ actual: round((Number(t) / m) * m, 2, SNAP),
117
+ expected: expectedRound(parseDecimal(t), 2),
118
+ }));
119
+ });
120
+
121
+ test("with snap: known values with float error from upstream arithmetic", () => {
122
+ const cases = [
123
+ // 3 * 0.075 in plain JS is 0.22499999999999998; the exact product is 0.225.
124
+ { value: 3 * 0.075, exact: "0.225" },
125
+ { value: -3 * 0.075, exact: "-0.225" },
126
+ // A garnishment withheld amount whose exact value is 602.015.
127
+ { value: 602.0149999999994, exact: "602.015" },
128
+ { value: -602.0149999999994, exact: "-602.015" },
129
+ { value: 95.01499999999942, exact: "95.015" },
130
+ ];
131
+ expectAll(cases, ({ value, exact }) => ({
132
+ actual: round(value, 2, SNAP),
133
+ expected: expectedRound(parseDecimal(exact), 2),
134
+ }));
135
+ });
136
+
137
+ test("without snap: values not on a half match exact rounding", () => {
138
+ const random = makeRandom(5);
139
+ function* cases() {
140
+ for (let i = 0; i < 200000; i++) {
141
+ const n = 1 + Math.floor(random() * 4); // 1..4
142
+ const str = randomDecimal(random, 1000000, n + 1 + Math.floor(random() * 2));
143
+ const dec = parseDecimal(str);
144
+ // Skip exact halves: whether 1.005 is a half depends on how it is stored.
145
+ const tail = dec.unscaled % 10n ** BigInt(dec.scale - n);
146
+ const half = 5n * 10n ** BigInt(dec.scale - n - 1);
147
+ if (tail === half || tail === -half) continue;
148
+ yield { str, n };
149
+ }
150
+ }
151
+ expectAll(cases(), ({ str, n }) => ({
152
+ actual: round(Number(str), n),
153
+ expected: expectedRound(parseDecimal(str), n),
154
+ }));
155
+ });
156
+
157
+ test("without snap: halves stored exactly in binary round away from zero", () => {
158
+ // k + j/8 and k + j/4 are exact doubles, so there is no ambiguity about the half.
159
+ function* cases() {
160
+ for (let k = 0; k <= 20000; k++) {
161
+ for (const fraction of ["125", "375", "625", "875"]) {
162
+ yield { str: `${k}.${fraction}`, n: 2 };
163
+ yield { str: `-${k}.${fraction}`, n: 2 };
164
+ }
165
+ for (const fraction of ["25", "75"]) {
166
+ yield { str: `${k}.${fraction}`, n: 1 };
167
+ yield { str: `-${k}.${fraction}`, n: 1 };
168
+ }
169
+ }
170
+ }
171
+ expectAll(cases(), ({ str, n }) => ({
172
+ actual: round(Number(str), n),
173
+ expected: expectedRound(parseDecimal(str), n),
174
+ }));
175
+ });
176
+
177
+ test("negative values round as the mirror of their positive value", () => {
178
+ // Rounding half away from zero is odd-symmetric: round(-x) = -round(x).
179
+ const random = makeRandom(6);
180
+ function* cases() {
181
+ for (let i = 0; i < 100000; i++) {
182
+ const n = 1 + Math.floor(random() * 4);
183
+ const str = randomDecimal(random, 1000000, Math.floor(random() * 7), false);
184
+ for (const options of [undefined, SNAP]) yield { str, n, options };
185
+ }
186
+ }
187
+ expectAll(cases(), ({ str, n, options }) => {
188
+ const positive = round(Number(str), n, options);
189
+ return { actual: round(-Number(str), n, options), expected: positive === 0 ? 0 : -positive };
190
+ });
191
+ });
192
+
193
+ test("rounding to zero gives 0, not -0", () => {
194
+ for (const value of [-0.004, -0.0049, -0.00001]) {
195
+ assert.ok(Object.is(round(value, 2), 0), `round(${value}, 2)`);
196
+ assert.ok(Object.is(round(value, 2, SNAP), 0), `round(${value}, 2, snap)`);
197
+ }
198
+ });
199
+
200
+ test("fractionDigits defaults to the configured value, which defaults to 2", () => {
201
+ assert.equal(new Calculator(2.3456).round(), expectedRound(parseDecimal("2.3456"), 2));
202
+ assert.equal(
203
+ new Calculator(2.3456, { fractionDigits: 3 }).round(),
204
+ expectedRound(parseDecimal("2.3456"), 3)
205
+ );
206
+ });
207
+
208
+ // Known limitations of 1.0.x, kept as todo tests so they show up in every run
209
+ // without failing it.
210
+ test(
211
+ "without snap: a decimal half that is not exact in binary rounds as written",
212
+ { todo: "round() scales with a float multiply (1.005 * 1000 = 1004.9999999999999); use { snap: true }" },
213
+ () => {
214
+ for (const str of ["1.005", "1.015", "4.015", "-1.005"]) {
215
+ assert.equal(round(Number(str), 2), expectedRound(parseDecimal(str), 2), str);
216
+ }
217
+ }
218
+ );
219
+
220
+ test(
221
+ "with snap: float error from cancellation (large a + large negative b) is removed",
222
+ {
223
+ todo:
224
+ "snap's allowance scales with the result, but the error of -615403.718 + 613501.153 " +
225
+ "scales with the operands, so it is about 25x too large; compute such values with NC instead",
226
+ },
227
+ () => {
228
+ const cases = [
229
+ { a: "-615403.718", b: "613501.153" }, // exact -1902.565
230
+ { a: "568756.460", b: "-565399.545" }, // exact 3356.915
231
+ { a: "-917513.753", b: "917501.658" }, // exact -12.095
232
+ ];
233
+ for (const { a, b } of cases) {
234
+ assert.equal(
235
+ round(Number(a) + Number(b), 2, SNAP),
236
+ expectedRound(addDecimal(parseDecimal(a), parseDecimal(b)), 2),
237
+ `${a} + ${b}`
238
+ );
239
+ }
240
+ }
241
+ );
242
+
243
+ test(
244
+ "round(0) rounds to an integer",
245
+ { todo: "fractionDigits 0 currently falls back to the default of 2" },
246
+ () => {
247
+ assert.equal(round(2.5, 0), expectedRound(parseDecimal("2.5"), 0));
248
+ }
249
+ );