A figure is a whole number token on both sides, grouping commas included; the check lives in hack/token.mjs with its counterexamples
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
3 files changed,
+23
−4
copy-review/pricing/build.mjs+4 −4hack/token.mjs+5 −0test/pricing-pricebook.test.mjs+14 −0
diff --git a/copy-review/pricing/build.mjs b/copy-review/pricing/build.mjs| index 470b5b2..a6e62c4 100644 |
| --- a/copy-review/pricing/build.mjs |
| +++ b/copy-review/pricing/build.mjs |
| @@ -5,6 +5,7 @@ |
| // strings.json; never edit the rendered files by hand. |
| import fs from "node:fs"; |
| import path from "node:path"; |
| +import { carriesToken } from "../../hack/token.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")); |
| @@ -142,10 +143,9 @@ for (const r of recs) byBand[r.band].push(r.name); |
| // must checks that an approved string carries a figure as a whole token |
| // (so "10" is not found inside "100"), not merely as a substring. |
| const must = (id, text, what) => { |
| - // A whole token: not followed by a digit, a decimal point, or a |
| - // grouping comma that continues the number ("10" is not in "10,000"). |
| - const re = new RegExp("(^|[^0-9.])" + text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "(?![0-9.]|,[0-9])"); |
| - if (!re.test(S[id])) throw new Error(`${id} (${JSON.stringify(S[id])}) does not carry ${what} ${JSON.stringify(text)} from the book`); |
| + // A whole token (hack/token.mjs): not part of a larger number on either |
| + // side, grouping commas included. |
| + if (!carriesToken(S[id], text)) throw new Error(`${id} (${JSON.stringify(S[id])}) does not carry ${what} ${JSON.stringify(text)} from the book`); |
| }; |
| for (const p of ["community", "business"]) must(`plan.${p}.min`, String(plansByKey[p].min_quantity), "the minimum"); |
| for (const [b, id] of [["B", "bands.b"], ["C", "bands.c"], ["D", "bands.d"]]) must(id, (book.band_factors_bps[b] / 100) + "%", "the band percentage"); |
diff --git a/hack/token.mjs b/hack/token.mjs| new file mode 100644 |
| index 0000000..4ae1b80 |
| --- /dev/null |
| +++ b/hack/token.mjs |
| @@ -0,0 +1,5 @@ |
| +// A figure appears in an approved string as a whole number token: not |
| +// inside a larger number on either side, a grouping comma included ("10" |
| +// is in neither "100", "10,000" nor "1,10x"; "250 MB" is not in "1,250 MB"). |
| +export const tokenPattern = (text) => new RegExp("(^|[^0-9.,])" + text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "(?![0-9.]|,[0-9])"); |
| +export const carriesToken = (haystack, text) => tokenPattern(text).test(haystack); |
diff --git a/test/pricing-pricebook.test.mjs b/test/pricing-pricebook.test.mjs| index 3e0ee83..aa8bda2 100644 |
| --- a/test/pricing-pricebook.test.mjs |
| +++ b/test/pricing-pricebook.test.mjs |
| @@ -7,6 +7,7 @@ import { readFileSync } from 'node:fs'; |
| import { execFileSync } from 'node:child_process'; |
| import assert from 'node:assert/strict'; |
| import { test } from 'node:test'; |
| +import { carriesToken } from '../hack/token.mjs'; |
| const root = new URL('../', import.meta.url); |
| const book = JSON.parse(readFileSync(new URL('copy-review/pricing/pricebook.json', root), 'utf8')); |
| @@ -229,3 +230,16 @@ test('every priced element shows, as static text, exactly its own USD value, and |
| assert.ok(inBook || inStrings, amount + ' (' + code + ') is on the page and in neither the book nor an approved string'); |
| } |
| }); |
| + |
| +test('a figure is carried only as a whole number token, grouping commas on either side included', () => { |
| + for (const [text, figure, want] of [ |
| + ['Minimum 10 members', '10', true], |
| + ['Minimum 10,000 members', '10', false], |
| + ['Minimum 100 members', '10', false], |
| + ['250 MB of storage', '250 MB', true], |
| + ['1,250 MB of storage', '250 MB', false], |
| + ['1,100 awake hours a month', '100 awake hours', false], |
| + ['25%', '25%', true], |
| + ['125%', '25%', false], |
| + ]) assert.equal(carriesToken(text, figure), want, `${JSON.stringify(figure)} in ${JSON.stringify(text)}`); |
| +}); |