carlos / carloku Public

Check every amount on the page, in all three currencies, against the book or an approved string

The euro-only check searched the static USD page for euro amounts and
found none; this one reads the per-currency and per-band attributes too,
so an amount typed in the script in any currency fails.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 251c4b192eab25104c96e68aac4135562b7cdd87 parent 3f1610d
1 file changed, +26 −6
  • test/pricing-pricebook.test.mjs +26 −6
diff --git a/test/pricing-pricebook.test.mjs b/test/pricing-pricebook.test.mjs
index 55782ff..1073141 100644
--- a/test/pricing-pricebook.test.mjs
+++ b/test/pricing-pricebook.test.mjs
@@ -159,12 +159,32 @@ test('a page built from a draft book says so on its first line, and the check re
execFileSync('node', [build], { stdio: 'pipe', env: envWith('1') });
});
-test('no euro amount on the page is typed in the script: each comes from a string or the book', () => {
- const stripped = page.replace(/data-(currency|band)-values="[^"]*"/g, '');
- for (const m of stripped.matchAll(/€[0-9][0-9.,]*/g)) {
- const amount = m[0];
- const inBook = Object.values(book.plans).some((p) => Object.values(p.annual_unit_minor.eur).some((minor) => money('€', minor) === amount));
+test('no amount on the page, in any currency, is typed in the script: each comes from the book or an approved string', () => {
+ // Every priced value the page carries, static text and the per-currency
+ // and per-band attributes alike, in all three currencies: each amount
+ // in it is a book amount (an annual price at some band, a per-member
+ // share of one, an overage rate or floor) or sits in an approved string.
+ // A micro amount as the page may spell it: as short as it is exact, or
+ // to two, three or four decimals (floors to two, rates to three or four).
+ const micro = (sym, m) => [(m / 1e6).toString(), (m / 1e6).toFixed(2), (m / 1e6).toFixed(3), (m / 1e6).toFixed(4)].map((t) => sym + t);
+ const rates = (code) => Object.values(book.overage[code]).flatMap((m) => micro(sym[code.toUpperCase()], m));
+ const annual = (code) => book.plans.flatMap((p) => Object.values(p.annual_unit_minor[code]).map((minor) => money(sym[code.toUpperCase()], minor)));
+ const known = Object.fromEntries(book.currencies.map((c) => [c.code.toUpperCase(), new Set([...annual(c.code), ...rates(c.code)])]));
+ const candidates = [];
+ for (const m of page.replace(/data-(currency|band)-values="[^"]*"/g, '').matchAll(/[€$£][0-9][0-9.,]*/g)) candidates.push(m[0]);
+ for (const v of valuesOf(page)) for (const [cur, text] of Object.entries(v)) for (const m of String(text).matchAll(/[€$£][0-9][0-9.,]*/g)) candidates.push(m[0]);
+ for (const v of bandValuesOf(page)) for (const perBand of Object.values(v)) for (const text of Object.values(perBand)) for (const m of String(text).matchAll(/[€$£][0-9][0-9.,]*/g)) candidates.push(m[0]);
+ assert.ok(candidates.length > 20, `found ${candidates.length} amounts to check`);
+ const bySymbol = { '€': 'EUR', '$': 'USD', '£': 'GBP' };
+ for (const amount of candidates) {
+ const code = bySymbol[amount[0]];
+ const inBook = known[code].has(amount) || known[code].has(amount.replace(/\.0+$/, ''));
const inStrings = strings.some((s) => s.text.includes(amount));
- assert.ok(inBook || inStrings, amount + ' is on the page and in neither the book nor an approved string');
+ const share = (() => {
+ // A per-member share of an annual price shown to two decimals.
+ const n = Number(amount.slice(1));
+ return book.plans.some((p) => Object.values(p.annual_unit_minor[code.toLowerCase()]).some((minor) => Math.abs(minor / 100 - n) < 0.005 || Math.abs(minor / 100 / 12 - n) < 0.005));
+ })();
+ assert.ok(inBook || inStrings || share, amount + ' (' + code + ') is on the page and in neither the book nor an approved string');
}
});