diff --git a/package-lock.json b/package-lock.json index f1e5b86..9fe311a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,6 +63,7 @@ "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.26", + "fast-check": "^4.8.0", "globals": "^17.7.0", "jsdom": "^29.1.1", "knip": "^6.24.0", @@ -9100,6 +9101,29 @@ "node": ">=6.6.0" } }, + "node_modules/fast-check": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.8.0.tgz", + "integrity": "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -12386,6 +12410,23 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.1.tgz", + "integrity": "sha512-c58R2+SPFcSIPXoU834QN/KPDDOSd8sXcSrqf6e83Me6Rrp1EYkxukkjXMVrKvKaADs1SOyNkWdfvLf6zY8qLQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/pvtsutils": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", diff --git a/package.json b/package.json index 31150c6..a0992a2 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.26", + "fast-check": "^4.8.0", "globals": "^17.7.0", "jsdom": "^29.1.1", "knip": "^6.24.0", diff --git a/tests/moneyProperties.test.js b/tests/moneyProperties.test.js new file mode 100644 index 0000000..1e8cc23 --- /dev/null +++ b/tests/moneyProperties.test.js @@ -0,0 +1,58 @@ +'use strict'; + +// Property-based proof of the money invariants. A financial app must not sample +// its cent math — it must hold for all inputs. Fuzzes utils/money.mts. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fc = require('fast-check'); + +let money; +test.before(async () => { + money = await import('../utils/money.mts'); // ESM .mts +}); + +// Realistic dollar amounts for this app (up to ±$100M), finite only. +const dollars = () => fc.double({ min: -1e8, max: 1e8, noNaN: true }); + +test('toCents always yields an integer for finite input', () => { + fc.assert(fc.property(dollars(), (d) => Number.isInteger(money.toCents(d)))); +}); + +test('dollars → cents → dollars equals roundMoney (no float drift)', () => { + fc.assert( + fc.property(dollars(), (d) => money.fromCents(money.toCents(d)) === money.roundMoney(d)), + ); +}); + +test('sumMoney is cent-exact (equals the sum taken in integer cents)', () => { + fc.assert( + fc.property(fc.array(dollars(), { maxLength: 60 }), (arr) => { + const cents = arr.reduce((s, d) => s + money.toCents(d), 0); + return money.sumMoney(arr) === money.fromCents(cents); + }), + ); +}); + +test('integer cents round-trip back to themselves', () => { + fc.assert( + fc.property( + fc.integer({ min: -1e9, max: 1e9 }), + (c) => money.toCents(money.fromCents(c)) === c, + ), + ); +}); + +test('classic float-drift cases are exact', () => { + assert.equal(money.sumMoney([0.1, 0.2]), 0.3); + assert.equal(money.sumMoney([0.1, 0.1, 0.1]), 0.3); + assert.equal(money.toCents(1.005), 101); // half-up on the shortest round-tripping decimal + assert.equal(money.toCents('$1,234.56'), 123456); +}); + +test('invalid inputs never produce a bogus number', () => { + assert.equal(money.toCents(null), null); + assert.equal(money.toCents(''), null); + assert.equal(money.fromCents(null), null); + assert.equal(money.fromCents(undefined), null); + assert.ok(Number.isNaN(money.toCents('not-money'))); +});