From 55985eeca36d5afb79ff299903080d1e7e0ee7c9 Mon Sep 17 00:00:00 2001 From: Yauhen Bichel Date: Sun, 13 Sep 2026 11:32:51 +0100 Subject: [PATCH] Make scale-reference stateless Remove the shared settings object (configure/getConfig/resetConfig). Settings are passed per call: fromEllipse(ellipse, diameterMm, options), explain(reason, messages). Defaults are frozen constants; an unknown or non-positive option throws. A test fails on any module-level let/var. Version 0.2.0 (breaking; nothing published yet). Co-Authored-By: Claude Opus 5 --- CONTRIBUTING.md | 7 ++++ README.md | 44 ++++++++++++++++---- __tests__/ScaleReference.test.js | 64 ++++++++++++++++++++++++++-- package-lock.json | 4 +- package.json | 2 +- src/ScaleReference.js | 63 ++++++++++++++++++++-------- src/config.js | 71 -------------------------------- src/defaults.js | 30 ++++++++++++++ src/index.js | 2 +- 9 files changed, 183 insertions(+), 104 deletions(-) delete mode 100644 src/config.js create mode 100644 src/defaults.js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a266c67..6c4c76a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,6 +22,13 @@ decision, will be declined, however good the code is. If you are unsure which side of the line a change sits on, open an issue and ask before writing the code. +## Stateless by design + +The package keeps no state and has no global settings. Every setting is an +argument, with its default in `src/defaults.js`, and module scope holds frozen +constants only (a test fails on a module-level `let` or `var`). Please don't +add a `configure()`, a cache or a singleton; add an option instead. + ## Getting set up ```bash diff --git a/README.md b/README.md index bd2252e..cf4c85b 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,34 @@ short axis does not. Photos tilted more than `maxTiltDegrees` (30° by default), or where the reference is smaller than `minReferencePixels` (40 px), are refused rather than measured. +## No state, no global settings + +Every method is a pure function of its arguments. The package keeps nothing +between calls and has no global configuration, so two parts of an app (or two +libraries) can use different settings without affecting each other. Pass +settings with the call: + +```js +import {ScaleReference, DEFAULT_OPTIONS} from '@molecare/scale-reference'; + +const options = {...DEFAULT_OPTIONS, maxTiltDegrees: 20}; +const scale = ScaleReference.fromEllipse(ellipse, diameterMm, options); +``` + +An unknown option, or one that is not a positive number, throws a `TypeError` +rather than being ignored. + +Messages from `explain` are plain English. To use your own wording or a +translation, pass it in, keyed by reason, with `default` for anything else: + +```js +ScaleReference.explain(scale.reason, { + reference_too_small: t('scale.tooSmall'), + reference_too_tilted: t('scale.tooTilted'), + default: t('scale.cannotMeasure'), +}); +``` + ## Limits Treat every result as an estimate with an error of its own. The main sources: @@ -78,17 +106,17 @@ accuracy. | `USD_QUARTER` | US quarter | 24.26 mm | yes | | `USD_PENNY` | US penny | 19.05 mm | yes | -Add or replace references: +The table is frozen and nothing in the package reads it for you. It is only a +convenience: `fromEllipse` takes any diameter, so keep your own references in +your app: ```js -import {configure, DEFAULT_REFERENCES} from '@molecare/scale-reference'; +const MY_REFERENCES = { + ...DEFAULT_REFERENCES, + CUSTOM_DOT_8MM: {label: '8 mm dot', diameterMm: 8, exact: true}, +}; -configure({ - references: { - ...DEFAULT_REFERENCES, - CUSTOM_DOT_8MM: {label: '8 mm dot', diameterMm: 8, exact: true}, - }, -}); +ScaleReference.fromEllipse(ellipse, MY_REFERENCES.CUSTOM_DOT_8MM.diameterMm); ``` ## Contributing diff --git a/__tests__/ScaleReference.test.js b/__tests__/ScaleReference.test.js index e7ee283..6885641 100644 --- a/__tests__/ScaleReference.test.js +++ b/__tests__/ScaleReference.test.js @@ -199,14 +199,72 @@ describe('the reference table', () => { }); it('cannot be changed for everyone by mutating a default entry', () => { - // getConfig() copies the catalog, but the entries were shared objects: one - // module changing a diameter changed it for the whole app. - const {DEFAULT_REFERENCES} = require('../src/config'); + const {DEFAULT_REFERENCES} = require('../src/defaults'); + expect(Object.isFrozen(DEFAULT_REFERENCES)).toBe(true); expect(Object.isFrozen(DEFAULT_REFERENCES.STICKER_10MM)).toBe(true); expect(() => { 'use strict'; ScaleReference.REFERENCES.STICKER_10MM.diameterMm = 99; }).toThrow(TypeError); + expect(() => { + 'use strict'; + ScaleReference.REFERENCES.CUSTOM = {label: 'x', diameterMm: 5, exact: true}; + }).toThrow(TypeError); expect(ScaleReference.REFERENCES.STICKER_10MM.diameterMm).toBe(10); }); }); + +describe('no state: settings go with each call', () => { + it('uses the options of one call without changing the next', () => { + // A 30 px sticker is refused by default (40 px minimum). + const relaxed = ScaleReference.fromEllipse(flat(30), STICKER, {minReferencePixels: 20}); + const plain = ScaleReference.fromEllipse(flat(30), STICKER); + + expect(relaxed.usable).toBe(true); + expect(plain.reason).toBe('reference_too_small'); + }); + + it('lets a caller choose its own tilt limit', () => { + expect(ScaleReference.fromEllipse(tilted(200, 25), STICKER, {maxTiltDegrees: 20}).reason).toBe( + 'reference_too_tilted', + ); + expect(ScaleReference.fromEllipse(tilted(200, 25), STICKER).usable).toBe(true); + }); + + it('refuses a mistyped or meaningless option instead of ignoring it', () => { + expect(() => ScaleReference.fromEllipse(flat(200), STICKER, {maxTilt: 10})).toThrow(/maxTilt/); + expect(() => ScaleReference.fromEllipse(flat(200), STICKER, {minReferencePixels: 0})).toThrow( + TypeError, + ); + expect(() => + ScaleReference.fromEllipse(flat(200), STICKER, {maxTiltDegrees: '30'}), + ).toThrow(TypeError); + }); + + it('takes the text of an explanation from the caller when given', () => { + const messages = {reference_too_small: 'Trop petit.', default: 'Impossible.'}; + + expect(ScaleReference.explain('reference_too_small', messages)).toBe('Trop petit.'); + expect(ScaleReference.explain('something_else', messages)).toBe('Impossible.'); + expect(ScaleReference.explain('reference_too_small')).toMatch(/too small/); + }); + + it('exports no global configuration', () => { + const api = require('../src'); + expect(api.configure).toBeUndefined(); + expect(api.getConfig).toBeUndefined(); + expect(api.resetConfig).toBeUndefined(); + expect(Object.isFrozen(api.DEFAULT_OPTIONS)).toBe(true); + }); + + it('has no module-level variables in its source', () => { + // The rule that keeps it stateless: module scope holds constants only. + const fs = require('fs'); + const path = require('path'); + const srcDir = path.join(__dirname, '..', 'src'); + for (const file of fs.readdirSync(srcDir).filter(f => f.endsWith('.js'))) { + const source = fs.readFileSync(path.join(srcDir, file), 'utf8'); + expect([file, /^(let|var)\s/m.test(source)]).toEqual([file, false]); + } + }); +}); diff --git a/package-lock.json b/package-lock.json index 281704a..b68f889 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@molecare/scale-reference", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@molecare/scale-reference", - "version": "0.1.0", + "version": "0.2.0", "license": "Apache-2.0", "devDependencies": { "@babel/core": "^7.24.0", diff --git a/package.json b/package.json index 2d55a9c..13a161c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@molecare/scale-reference", - "version": "0.1.0", + "version": "0.2.0", "description": "Estimate sizes in a photo from a reference object of known size (sticker or coin): pixel to millimetre scale, tilt checks and comparability. Pure JavaScript.", "main": "src/index.js", "license": "Apache-2.0", diff --git a/src/ScaleReference.js b/src/ScaleReference.js index 02b96ba..00e4519 100644 --- a/src/ScaleReference.js +++ b/src/ScaleReference.js @@ -1,36 +1,34 @@ -import {getConfig} from './config'; +import {DEFAULT_OPTIONS, DEFAULT_REFERENCES} from './defaults'; /** * Turning pixels into millimetres, given something of known size in the frame. * * Scale comes from the major axis alone: a circle photographed off-perpendicular * projects to an ellipse whose major axis keeps the true diameter. + * + * Stateless: every method is a pure function of its arguments. Settings are + * passed per call; there is no global configuration to change. */ export default class ScaleReference { - /** - * Diameters in millimetres of known reference objects. - * Override the whole catalog via configure({ references }). - */ - static get REFERENCES() { - return getConfig().references; - } + /** Known reference objects (frozen). Pass any diameter you like instead. */ + static REFERENCES = DEFAULT_REFERENCES; - static get MAX_TILT_DEGREES() { - return getConfig().maxTiltDegrees; - } + static MAX_TILT_DEGREES = DEFAULT_OPTIONS.maxTiltDegrees; - static get MIN_REFERENCE_PIXELS() { - return getConfig().minReferencePixels; - } + static MIN_REFERENCE_PIXELS = DEFAULT_OPTIONS.minReferencePixels; /** * Scale from a detected reference. * * @param {{majorAxisPx: number, minorAxisPx: number}} ellipse * @param {number} diameterMm + * @param {{maxTiltDegrees?: number, minReferencePixels?: number}} [options] + * defaults in DEFAULT_OPTIONS * @returns {{mmPerPixel: number, tiltDegrees: number, usable: boolean, reason: string|null}|null} + * @throws {TypeError} for an unknown option or one that is not a positive number */ - static fromEllipse(ellipse, diameterMm) { + static fromEllipse(ellipse, diameterMm, options) { + const {maxTiltDegrees, minReferencePixels} = ScaleReference._options(options); const major = ScaleReference._positive(ellipse && ellipse.majorAxisPx); const minor = ScaleReference._positive(ellipse && ellipse.minorAxisPx); const mm = ScaleReference._positive(diameterMm); @@ -47,9 +45,9 @@ export default class ScaleReference { (Math.acos(Math.min(1, shortAxis / longAxis)) * 180) / Math.PI; let reason = null; - if (longAxis < ScaleReference.MIN_REFERENCE_PIXELS) { + if (longAxis < minReferencePixels) { reason = 'reference_too_small'; - } else if (tiltDegrees > ScaleReference.MAX_TILT_DEGREES) { + } else if (tiltDegrees > maxTiltDegrees) { reason = 'reference_too_tilted'; } @@ -104,7 +102,20 @@ export default class ScaleReference { return (larger - smaller) / larger <= tolerance; } - static explain(reason) { + /** + * A plain English explanation of a refusal. + * + * @param {string} reason - `reason` from fromEllipse + * @param {Object} [messages] - your own text (for example a + * translation) keyed by reason; `default` covers any other reason + */ + static explain(reason, messages) { + if (messages && typeof messages[reason] === 'string') { + return messages[reason]; + } + if (messages && typeof messages.default === 'string') { + return messages.default; + } switch (reason) { case 'reference_too_small': return 'The reference object is too small in the photo to measure from. Move closer, or place it nearer the subject.'; @@ -115,6 +126,22 @@ export default class ScaleReference { } } + static _options(options) { + if (options === undefined || options === null) { + return DEFAULT_OPTIONS; + } + for (const key of Object.keys(options)) { + if (!Object.prototype.hasOwnProperty.call(DEFAULT_OPTIONS, key)) { + throw new TypeError(`Unknown ScaleReference option "${key}"`); + } + const value = options[key]; + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + throw new TypeError(`ScaleReference option "${key}" must be a positive number`); + } + } + return {...DEFAULT_OPTIONS, ...options}; + } + static _positive(value) { const n = typeof value === 'number' ? value : Number(value); return Number.isFinite(n) && n > 0 ? n : null; diff --git a/src/config.js b/src/config.js deleted file mode 100644 index 063d48e..0000000 --- a/src/config.js +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Runtime configuration for @molecare/scale-reference. - * REFERENCES catalog is injectable so apps can add/remove known objects. - * - * `exact` means the object is round, so an ellipse fitted to it has the - * published diameter as its major axis. A polygon (the 12-sided £1, the - * seven-sided 20p) does not fit an ellipse exactly, so its scale is rougher. - * Diameters are the issuers' published figures. - */ - -const reference = (label, diameterMm, exact) => - Object.freeze({label, diameterMm, exact}); - -export const DEFAULT_REFERENCES = Object.freeze({ - STICKER_10MM: reference('10mm calibration sticker', 10, true), - // Royal Mint: 12-sided, 23.43 mm. - GBP_1: reference('UK £1', 23.43, false), - GBP_2: reference('UK £2', 28.4, true), - // Royal Mint: seven-sided equal-width curve, 21.4 mm. - GBP_20P: reference('UK 20p', 21.4, false), - EUR_1: reference('€1', 23.25, true), - EUR_2: reference('€2', 25.75, true), - USD_QUARTER: reference('US quarter', 24.26, true), - USD_PENNY: reference('US penny', 19.05, true), -}); - -const DEFAULTS = Object.freeze({ - maxTiltDegrees: 30, - minReferencePixels: 40, - references: DEFAULT_REFERENCES, -}); - -let config = { - maxTiltDegrees: DEFAULTS.maxTiltDegrees, - minReferencePixels: DEFAULTS.minReferencePixels, - references: {...DEFAULT_REFERENCES}, -}; - -export function configure(partial = {}) { - config = { - maxTiltDegrees: - partial.maxTiltDegrees !== undefined - ? partial.maxTiltDegrees - : config.maxTiltDegrees, - minReferencePixels: - partial.minReferencePixels !== undefined - ? partial.minReferencePixels - : config.minReferencePixels, - references: - partial.references !== undefined - ? {...partial.references} - : config.references, - }; - return getConfig(); -} - -export function getConfig() { - return { - maxTiltDegrees: config.maxTiltDegrees, - minReferencePixels: config.minReferencePixels, - references: {...config.references}, - }; -} - -export function resetConfig() { - config = { - maxTiltDegrees: DEFAULTS.maxTiltDegrees, - minReferencePixels: DEFAULTS.minReferencePixels, - references: {...DEFAULT_REFERENCES}, - }; -} diff --git a/src/defaults.js b/src/defaults.js new file mode 100644 index 0000000..dcef4e2 --- /dev/null +++ b/src/defaults.js @@ -0,0 +1,30 @@ +/** + * Constants only. Nothing in this package keeps state: settings are passed to + * each call, and these are the values used when a caller passes none. + * + * `exact` means the object is round, so an ellipse fitted to it has the + * published diameter as its major axis. A polygon (the 12-sided £1, the + * seven-sided 20p) does not fit an ellipse exactly, so its scale is rougher. + * Diameters are the issuers' published figures. + */ + +const reference = (label, diameterMm, exact) => + Object.freeze({label, diameterMm, exact}); + +export const DEFAULT_REFERENCES = Object.freeze({ + STICKER_10MM: reference('10mm calibration sticker', 10, true), + // Royal Mint: 12-sided, 23.43 mm. + GBP_1: reference('UK £1', 23.43, false), + GBP_2: reference('UK £2', 28.4, true), + // Royal Mint: seven-sided equal-width curve, 21.4 mm. + GBP_20P: reference('UK 20p', 21.4, false), + EUR_1: reference('€1', 23.25, true), + EUR_2: reference('€2', 25.75, true), + USD_QUARTER: reference('US quarter', 24.26, true), + USD_PENNY: reference('US penny', 19.05, true), +}); + +export const DEFAULT_OPTIONS = Object.freeze({ + maxTiltDegrees: 30, + minReferencePixels: 40, +}); diff --git a/src/index.js b/src/index.js index 7fd2722..1d33fd3 100644 --- a/src/index.js +++ b/src/index.js @@ -1,3 +1,3 @@ -export {configure, getConfig, resetConfig, DEFAULT_REFERENCES} from './config'; +export {DEFAULT_OPTIONS, DEFAULT_REFERENCES} from './defaults'; export {default as ScaleReference} from './ScaleReference'; export {default} from './ScaleReference';