test(money): property-based proof of cent-exact invariants (fast-check, Pillar F)
A financial app must prove its money math, not sample it. tests/ moneyProperties.test.js fuzzes utils/money.mts: toCents always integer; dollars→cents→dollars == roundMoney (no drift); sumMoney is cent-exact; integer cents round-trip; classic float-drift cases exact; invalid inputs never yield a bogus number. 6 properties, all hold. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
b4634a8080
commit
0cad1626b1
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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')));
|
||||
});
|
||||
Loading…
Reference in New Issue