The money renderer is a module the test calls: a fractional minor unit is refused by moneyText, asserted by calling it, not by reading the source
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
3 files changed,
+20
−8
copy-review/pricing/build.mjs+2 −5hack/money.mjs+10 −0test/pricing-pricebook.test.mjs+8 −3
diff --git a/copy-review/pricing/build.mjs b/copy-review/pricing/build.mjs| index 7ca92c2..81d63ec 100644 |
| --- a/copy-review/pricing/build.mjs |
| +++ b/copy-review/pricing/build.mjs |
| @@ -6,6 +6,7 @@ |
| import fs from "node:fs"; |
| import path from "node:path"; |
| import { carriesToken } from "../../hack/token.mjs"; |
| +import { moneyText } from "../../hack/money.mjs"; |
| const here = path.dirname(new URL(import.meta.url).pathname); |
| const root = path.resolve(here, "..", ".."); |
| const strings = JSON.parse(fs.readFileSync(path.join(here, "strings.json"), "utf8")); |
| @@ -38,11 +39,7 @@ const symbol = Object.fromEntries(book.currencies.map((c) => [c.code.toUpperCase |
| const plansByKey = Object.fromEntries(book.plans.map((pl) => [pl.key, pl])); |
| // money renders minor units as the approved strings do: "€24", "€8.40", |
| // "$1,575" is not needed (no price reaches a thousand). |
| -const money = (cur, minor) => { |
| - if (!Number.isInteger(minor)) throw new Error("not a whole number of minor units: " + cur + " " + minor); |
| - const major = Math.floor(minor / 100), rem = minor % 100; |
| - return symbol[cur] + major + (rem ? "." + String(rem).padStart(2, "0") : ""); |
| -}; |
| +const money = (cur, minor) => moneyText(symbol[cur], minor, cur); |
| // rate renders micro units exactly: at least three decimals, as the |
| // approved EUR rate strings are written ("€0.010 per hour"), and as many |
| // more as the figure needs ("$0.1272", "£0.0207"), never rounded. What the |
diff --git a/hack/money.mjs b/hack/money.mjs| new file mode 100644 |
| index 0000000..b08d135 |
| --- /dev/null |
| +++ b/hack/money.mjs |
| @@ -0,0 +1,10 @@ |
| +// moneyText renders a whole number of minor units as the approved strings |
| +// do: "€24", "€8.40". A minor amount that is not a whole number is refused |
| +// rather than rendered ("$1.20.5" was the alternative): the price book |
| +// carries whole cents, and a floor that is not would be a book error, not |
| +// a formatting case. |
| +export function moneyText(symbol, minor, label) { |
| + if (!Number.isInteger(minor)) throw new Error("not a whole number of minor units: " + label + " " + minor); |
| + const major = Math.floor(minor / 100), rem = minor % 100; |
| + return symbol + major + (rem ? "." + String(rem).padStart(2, "0") : ""); |
| +} |
diff --git a/test/pricing-pricebook.test.mjs b/test/pricing-pricebook.test.mjs| index f585b03..ecacde7 100644 |
| --- a/test/pricing-pricebook.test.mjs |
| +++ b/test/pricing-pricebook.test.mjs |
| @@ -8,6 +8,7 @@ import { execFileSync } from 'node:child_process'; |
| import assert from 'node:assert/strict'; |
| import { test } from 'node:test'; |
| import { carriesToken } from '../hack/token.mjs'; |
| +import { moneyText } from '../hack/money.mjs'; |
| const root = new URL('../', import.meta.url); |
| const book = JSON.parse(readFileSync(new URL('copy-review/pricing/pricebook.json', root), 'utf8')); |
| @@ -244,10 +245,14 @@ test('every priced element shows, as static text, exactly its own USD value, and |
| }); |
| test('a floor that is not a whole number of cents is refused by the renderer, not shown as a malformed amount', () => { |
| - // The renderer's money() is the one place a floor is spelt; it throws on |
| - // a fractional minor unit rather than printing "$1.20.5". |
| + // The renderer's money is moneyText, the one place a floor is spelt; it |
| + // throws on a fractional minor unit rather than printing "$1.20.5", and |
| + // the build's money() is that function with the currency's symbol. |
| + assert.throws(() => moneyText('$', 120.5, 'USD'), /not a whole number of minor units/); |
| + assert.equal(moneyText('$', 120, 'USD'), '$1.20'); |
| + assert.equal(moneyText('€', 2400, 'EUR'), '€24'); |
| const src = readFileSync(new URL('copy-review/pricing/build.mjs', root), 'utf8'); |
| - assert.ok(src.includes('if (!Number.isInteger(minor)) throw new Error("not a whole number of minor units'), 'money() refuses a fractional minor unit'); |
| + assert.ok(src.includes('const money = (cur, minor) => moneyText(symbol[cur], minor, cur);'), 'the build renders money through moneyText'); |
| for (const cur of Object.keys(book.overage)) assert.equal(book.overage[cur].invoice_floor_micro % 10000, 0, cur + ' floor is whole cents'); |
| }); |