diff --git a/README.md b/README.md index 36f4022..9946543 100755 --- a/README.md +++ b/README.md @@ -68,7 +68,8 @@ reduceCalc('min(50px, calc(2 * 40px))'); // => '50px' ``` -It accepts `precision`, `warnWhenCannotResolve`, `onParseError`, and `onWarn`: +It accepts `precision`, `unwrapSingleNegativeNumber`, `warnWhenCannotResolve`, `onParseError`, +and `onWarn`: ```js const result = reduceCalc('calc(100% + var(--gap))', { @@ -84,7 +85,30 @@ const result = reduceCalc('calc(100% + var(--gap))', { Unlike the PostCSS plugin, the standalone reducer does not show warnings by default; provide `onParseError` and/or `onWarn` if you want diagnostics. -### Options +### Standalone reducer options + +#### `unwrapSingleNegativeNumber` (default: `false`) + +Controls whether a finite negative result is serialized as a bare value or +wrapped in `calc()`. Keep the default when reducing declaration values; set it +to `true` when the surrounding CSS context requires a bare negative value, such +as a selector: + +```js +reduceCalc('calc(5px - 10px)'); +// => 'calc(-5px)' + +reduceCalc('calc(5px - 10px)', { unwrapNegativeNumbers: true }); +// => '-5px' +``` + +### PostCSS plugin options + +These options apply when using the PostCSS plugin: + +```js +postcss().use(calc({ precision: 10 })); +``` #### `precision` (default: `5`) @@ -139,7 +163,11 @@ With `mediaQueries: true`, this becomes: #### `selectors` (default: `false`) -Allows calc() usage as part of selectors. +Reduces `calc()` functions found in selectors. Selectors do not accept +`calc()` functions, so the plugin replaces them with their reduced values. +Finite negative results are serialized as bare values because a selector cannot +contain a `calc()` function; the plugin enables `unwrapSingleNegativeNumber` automatically +for selectors. ```js var out = postcss() @@ -163,11 +191,13 @@ Callback invoked when a `calc()` body fails to parse or simplify. Matches [`@csstools/css-calc`][csstools-css-calc]'s shape: ```js -calc({ - onParseError: (err, input) => { - throw err; // or log, route to a different channel, etc. - }, -}); +postcss().use( + calc({ + onParseError: (err, input) => { + throw err; // or log, route to a different channel, etc. + }, + }) +); ``` When omitted, errors are reported via PostCSS `result.warn()` so the diff --git a/src/index.js b/src/index.js index 730885b..39d161a 100644 --- a/src/index.js +++ b/src/index.js @@ -24,9 +24,17 @@ import reduceCalc, { hasPotentialMathFunction } from './reduce.js'; * @param {(target: import('postcss').ChildNode, value: string) => void} setProp * @param {ResolvedOptions} options * @param {import('postcss').Result} result + * @param {boolean} unwrapSingleNegativeNumber * @return {void} */ -function applyTransform(node, current, setProp, options, result) { +function applyTransform( + node, + current, + setProp, + options, + result, + unwrapSingleNegativeNumber +) { if (!hasPotentialMathFunction(current)) { return; } @@ -41,6 +49,7 @@ function applyTransform(node, current, setProp, options, result) { onWarn: (message) => { result.warn(message, { plugin: 'postcss-calc', node }); }, + unwrapSingleNegativeNumber, }); if (transformed !== current) { setProp(node, transformed); @@ -77,7 +86,8 @@ function pluginCreator(opts) { /** @type {import('postcss').Declaration} */ (n).value = v; }, options, - result + result, + false ); } if (node.type === 'atrule' && options.mediaQueries) { @@ -88,7 +98,8 @@ function pluginCreator(opts) { /** @type {import('postcss').AtRule} */ (n).params = v; }, options, - result + result, + false ); } if (node.type === 'rule' && options.selectors) { @@ -101,7 +112,8 @@ function pluginCreator(opts) { /** @type {import('postcss').Rule} */ (n).selector = v; }, options, - result + result, + true ); } }); diff --git a/src/lib/serialize.js b/src/lib/serialize.js index ad5cc54..7b58a35 100644 --- a/src/lib/serialize.js +++ b/src/lib/serialize.js @@ -1,7 +1,6 @@ // Spec: https://www.w3.org/TR/css-values-4/#serialize-a-calculation-tree -// Outer calc() is added only when the top-level result contains an -// arithmetic operator. A Sum inside a Product is the only place parens -// are ever required on valid canonical input. +// Outer calc() is added when the top-level result contains an arithmetic +// operator, or when a finite scalar is negative. import { num, dim } from './node.js'; @@ -13,6 +12,7 @@ import { num, dim } from './node.js'; * @typedef {object} SerializeOptions * @property {number | false} [precision] Decimal places for numbers. `false` disables rounding. Default 5. * @property {string} [calcName] Wrapper name to use when `calc()` is needed. Default `'calc'`. + * @property {boolean} [unwrapSingleNegativeNumber] Serialize finite negative scalars without a wrapper. Internal selector-only mode. */ // Below this is float noise, not a value: `0.1 + 0.2 - 0.3` is 5.5e-17. @@ -80,6 +80,20 @@ function serializeNumber(v) { return text; } +/** + * Round and serialize a finite scalar once so callers can use the same value + * to decide its syntactic context and render its text. + * + * @param {import('./node.js').Num | import('./node.js').Dim} node + * @param {number | false} prec + * @return {{value: number, text: string}} + */ +function serializeScalar(node, prec) { + const value = round(node.value, prec); + const text = `${serializeNumber(value)}${node.type === 'Dim' ? node.unit : ''}`; + return { value, text }; +} + /** * @param {Node} node * @param {SerializeOptions} [opts] @@ -98,6 +112,22 @@ function serialize(node, opts = {}) { return `${calcName}(${degenerateKeyword(node.value)} * 1${node.unit})`; } + if (node.type === 'Num' || node.type === 'Dim') { + const scalar = serializeScalar(node, prec); + + // A finite negative scalar must stay inside calc() so CSS parses it as a + // calculation result (and can apply range clamping) rather than as an + // invalid bare value. Base this on the serialized value so tiny negative + // floating-point noise that rounds to zero does not get wrapped. + if (scalar.value < 0) { + return opts.unwrapSingleNegativeNumber + ? scalar.text + : `${calcName}(${scalar.text})`; + } + + return scalar.text; + } + // A grouped sum with a leading negative term is the canonical result of // negating a parenthesized expression. Re-invert its terms for the body so // the grouping survives as `-(...)` instead of becoming `-a - b`. @@ -114,12 +144,7 @@ function serialize(node, opts = {}) { return `${calcName}(-(${serializeSumTerms(invertedTerms, prec)}))`; } - if ( - node.type === 'Num' || - node.type === 'Dim' || - node.type === 'Ident' || - node.type === 'Call' - ) { + if (node.type === 'Ident' || node.type === 'Call') { return serializeExpr(node, prec); } @@ -145,7 +170,7 @@ function serializeExpr(node, prec) { if (isDegenerate(node.value)) { return degenerateKeyword(node.value); } - return serializeNumber(round(node.value, prec)); + return serializeScalar(node, prec).text; case 'Dim': if (isDegenerate(node.value)) { // Nested degenerate Dim wraps in calc() so the ` * 1` form @@ -153,7 +178,7 @@ function serializeExpr(node, prec) { // inside a Product — `0 * Dim(Infinity, px)` would re-fold as NaN. return `calc(${degenerateKeyword(node.value)} * 1${node.unit})`; } - return `${serializeNumber(round(node.value, prec))}${node.unit}`; + return serializeScalar(node, prec).text; case 'Ident': return node.name; case 'Call': { diff --git a/src/reduce.js b/src/reduce.js index 46f3419..e3be230 100644 --- a/src/reduce.js +++ b/src/reduce.js @@ -27,6 +27,7 @@ const BLOCK_CLOSE = new Map([ * @typedef {object} ReduceCalcOptions * @property {number | false} [precision] * @property {boolean} [warnWhenCannotResolve] + * @property {boolean} [unwrapSingleNegativeNumber] Serialize finite negative results without a `calc()` wrapper. Defaults to `false`. * @property {(error: Error, input: string) => void} [onParseError] Invoked when parse/simplify throws. * @property {(message: string) => void} [onWarn] Invoked when `warnWhenCannotResolve` is set and an expression cannot be reduced to a single value. */ @@ -34,9 +35,7 @@ const BLOCK_CLOSE = new Map([ /** @typedef {Required> & Pick} ResolvedReduceCalcOptions */ /** - * Fields threaded unchanged through the token-range walk. - * `value` is the original full property text, used only for the - * warnWhenCannotResolve message. + * Fields threaded through the internal token-range walk. * * @typedef {object} TransformContext * @property {ResolvedReduceCalcOptions} options @@ -51,7 +50,6 @@ const BLOCK_CLOSE = new Map([ * @property {number} end * @property {import('./lib/node.js').Node} node * @property {string} calcName - * @property {string} matchedName */ /** @@ -105,7 +103,6 @@ function walkTokens(start, expectedClose, ctx, transform) { end, node, calcName: isCalc ? name : 'calc', - matchedName: name, }); } catch (error) { const err = error instanceof Error ? error : new Error('Error'); @@ -117,6 +114,17 @@ function walkTokens(start, expectedClose, ctx, transform) { return ctx.tokens.length - 1; } +/** + * @param {import('./lib/node.js').Node} node + * @return {boolean} + */ +function isUnresolvedResult(node) { + if (node.type === 'Sum' || node.type === 'Product') { + return true; + } + return node.type === 'Call' && isSupportedMathFunction(node.name); +} + /** * Simplify every supported CSS math function in a component-value string. * Text outside those functions is preserved byte-for-byte. @@ -131,7 +139,12 @@ function reduceCalc(value, opts) { } /** @type {ResolvedReduceCalcOptions} */ - const options = { precision: 5, warnWhenCannotResolve: false, ...opts }; + const options = { + precision: 5, + warnWhenCannotResolve: false, + unwrapSingleNegativeNumber: false, + ...opts, + }; const tokens = cssTokenize({ css: value }); /** @type {Replacement[]} */ const replacements = []; @@ -147,11 +160,9 @@ function reduceCalc(value, opts) { const text = serialize(replacement.node, { precision: options.precision, calcName: replacement.calcName, + unwrapSingleNegativeNumber: options.unwrapSingleNegativeNumber, }); - if ( - options.warnWhenCannotResolve && - text.startsWith(`${replacement.matchedName}(`) - ) { + if (options.warnWhenCannotResolve && isUnresolvedResult(replacement.node)) { options.onWarn?.('Could not reduce expression: ' + value); } output += value.slice(lastIndex, replacement.start) + text; diff --git a/test/conformance/csstools.test.mjs b/test/conformance/csstools.test.mjs index 37332f9..4f9a24f 100644 --- a/test/conformance/csstools.test.mjs +++ b/test/conformance/csstools.test.mjs @@ -200,7 +200,7 @@ describe('csstools round/mod/rem/abs/sign', () => { test('csstools round: each strategy', () => { assert.equal(out('round(up, 1.1, 1)'), '2'); assert.equal(out('round(down, 1.9, 1)'), '1'); - assert.equal(out('round(to-zero, -1.9, 1)'), '-1'); + assert.equal(out('round(to-zero, -1.9, 1)'), 'calc(-1)'); assert.equal(out('round(nearest, 1.5, 1)'), '2'); }); @@ -215,12 +215,12 @@ describe('csstools round/mod/rem/abs/sign', () => { test('csstools mod: spec examples', () => { assert.equal(out('mod(18, 5)'), '3'); assert.equal(out('mod(-18, 5)'), '2'); - assert.equal(out('mod(18, -5)'), '-2'); + assert.equal(out('mod(18, -5)'), 'calc(-2)'); }); test('csstools rem: spec examples', () => { assert.equal(out('rem(18, 5)'), '3'); - assert.equal(out('rem(-18, 5)'), '-3'); + assert.equal(out('rem(-18, 5)'), 'calc(-3)'); assert.equal(out('rem(18, -5)'), '3'); }); @@ -240,10 +240,10 @@ describe('csstools round/mod/rem/abs/sign', () => { }); test('csstools sign: number, dim, opaque', () => { - assert.equal(out('sign(-5)'), '-1'); + assert.equal(out('sign(-5)'), 'calc(-1)'); assert.equal(out('sign(5)'), '1'); assert.equal(out('sign(0)'), '0'); - assert.equal(out('sign(-5px)'), '-1'); + assert.equal(out('sign(-5px)'), 'calc(-1)'); assert.equal(out('sign(var(--x))'), 'sign(var(--x))'); }); @@ -290,7 +290,7 @@ describe('csstools trig:', () => { }); test('csstools trig: cos(180deg) → -1', () => { - assert.equal(out('cos(180deg)'), '-1'); + assert.equal(out('cos(180deg)'), 'calc(-1)'); }); test('csstools trig: cos(60deg) → 0.5000000000000001 (full precision)', () => { @@ -330,7 +330,7 @@ describe('csstools trig:', () => { }); test('csstools inverse-trig: asin(-1) → -90deg', () => { - assert.equal(out('asin(-1)'), '-90deg'); + assert.equal(out('asin(-1)'), 'calc(-90deg)'); }); test('csstools inverse-trig: asin(0.5) → 30.000000000000004deg', () => { @@ -370,7 +370,7 @@ describe('csstools trig:', () => { }); test('csstools atan2: (-1, -1) → -135deg', () => { - assert.equal(out('atan2(-1, -1)'), '-135deg'); + assert.equal(out('atan2(-1, -1)'), 'calc(-135deg)'); }); test('csstools atan2: cross-unit-same-base (1in, 96px) → 45deg', () => { diff --git a/test/conformance/wpt.test.mjs b/test/conformance/wpt.test.mjs index bf87bd0..199d453 100644 --- a/test/conformance/wpt.test.mjs +++ b/test/conformance/wpt.test.mjs @@ -20,8 +20,7 @@ import { out } from '../helpers/out.mjs'; // https://github.com/web-platform-tests/wpt/blob/master/css/css-values/calc-serialization.html test('WPT calc-serialization: single negative length preserved', () => { // Input: `calc(-10px)` → WPT expects `calc(-10px)`. - // DIVERGE: we unwrap single values to bare dimensions. - assert.equal(out('calc(-10px)'), '-10px'); + assert.equal(out('calc(-10px)'), 'calc(-10px)'); }); test('WPT calc-serialization: resolvable + opaque kept as a sum', () => { @@ -256,8 +255,7 @@ describe('WPT minmax-percentage: Multi-arg Percent', () => { // normalization changes the result. describe('WPT calc-serialization-002:', () => { test('WPT calc-serialization-002: same-family combination (ex)', () => { - // WPT: `calc(5ex - 9ex)` → `calc(-4ex)`. Our single-value unwrap: `-4ex`. - assert.equal(out('calc(5ex - 9ex)'), '-4ex'); + assert.equal(out('calc(5ex - 9ex)'), 'calc(-4ex)'); }); test('WPT calc-serialization-002: cancelled percentage preserved as 0%', () => { @@ -305,7 +303,7 @@ describe('WPT round:', () => { }); test('WPT round: round(to-zero, -19, 10) → -10', () => { - assert.equal(out('round(to-zero, -19, 10)'), '-10'); + assert.equal(out('round(to-zero, -19, 10)'), 'calc(-10)'); }); test('WPT round: round(3.7) → 4 (B defaults to 1 for )', () => { @@ -332,7 +330,7 @@ describe('WPT mod: Mod(18px 5px', () => { }); test('WPT mod: mod(140deg, -90deg) → -40deg (spec example)', () => { - assert.equal(out('mod(140deg, -90deg)'), '-40deg'); + assert.equal(out('mod(140deg, -90deg)'), 'calc(-40deg)'); }); }); @@ -344,7 +342,7 @@ describe('WPT rem:', () => { }); test('WPT rem: rem(-18px, 5px) → -3px (sign of A, spec example)', () => { - assert.equal(out('rem(-18px, 5px)'), '-3px'); + assert.equal(out('rem(-18px, 5px)'), 'calc(-3px)'); }); test('WPT rem: rem(140deg, -90deg) → 50deg (spec example)', () => { @@ -372,7 +370,7 @@ describe('WPT abs:', () => { // https://github.com/web-platform-tests/wpt/blob/master/css/css-values/signs-abs-computed.html describe('WPT sign:', () => { test('WPT sign: sign(-5) → -1', () => { - assert.equal(out('sign(-5)'), '-1'); + assert.equal(out('sign(-5)'), 'calc(-1)'); }); test('WPT sign: sign(0) → 0', () => { @@ -404,7 +402,7 @@ describe('WPT sin/cos/tan:', () => { }); test('WPT sin/cos/tan: cos(180deg) → -1', () => { - assert.equal(out('cos(180deg)'), '-1'); + assert.equal(out('cos(180deg)'), 'calc(-1)'); }); test('WPT sin/cos/tan: tan(45deg) → 1 (rounded from 0.999...)', () => { @@ -434,7 +432,7 @@ describe('WPT trig-pi:', () => { }); test('WPT trig-pi: cos(pi) → -1', () => { - assert.equal(out('cos(pi)'), '-1'); + assert.equal(out('cos(pi)'), 'calc(-1)'); }); test('WPT trig-pi: cos(2 * pi) → 1', () => { @@ -458,7 +456,7 @@ describe('WPT asin:', () => { }); test('WPT asin: asin(-1) → -90deg', () => { - assert.equal(out('asin(-1)'), '-90deg'); + assert.equal(out('asin(-1)'), 'calc(-90deg)'); }); test('WPT acos: acos(1) → 0deg', () => { @@ -482,7 +480,7 @@ describe('WPT asin:', () => { }); test('WPT atan: atan(-1) → -45deg', () => { - assert.equal(out('atan(-1)'), '-45deg'); + assert.equal(out('atan(-1)'), 'calc(-45deg)'); }); test('WPT atan: atan(infinity) → 90deg', () => { @@ -506,7 +504,7 @@ describe('WPT atan2:', () => { }); test('WPT atan2: atan2(-1, 0) → -90deg', () => { - assert.equal(out('atan2(-1, 0)'), '-90deg'); + assert.equal(out('atan2(-1, 0)'), 'calc(-90deg)'); }); test('WPT atan2: atan2(1, 1) → 45deg', () => { diff --git a/test/helpers/corpus-selection.mjs b/test/helpers/corpus-selection.mjs index 1f6dca1..0097c59 100644 --- a/test/helpers/corpus-selection.mjs +++ b/test/helpers/corpus-selection.mjs @@ -87,7 +87,7 @@ export function classifyCorpusExpression(input) { try { const literals = []; const ast = parse(tokenize(input)); - // A calc wrapper accepts exactly one expression. The harvested GitHub + // A calc() function accepts exactly one expression. The harvested GitHub // pool also contains malformed calc-like calls; those remain covered by // invalid-corpus resilience tests instead of becoming differential noise. if ( diff --git a/test/index.cjs b/test/index.cjs index 081ec6f..08ce14d 100644 --- a/test/index.cjs +++ b/test/index.cjs @@ -432,7 +432,7 @@ describe('Keep', () => { test( 'should keep a negative value smaller than the precision', - testValue('calc(-1/1000000)', '-.000001') + testValue('calc(-1/1000000)', 'calc(-.000001)') ); test( @@ -517,7 +517,7 @@ test( describe('Reduce', () => { test( 'should reduce substraction from zero', - testValue('calc( 0 - 10px)', '-10px') + testValue('calc( 0 - 10px)', 'calc(-10px)') ); test( @@ -1057,13 +1057,13 @@ describe('Plus', () => { }); describe('Minus', () => { - test('minus sign', testValue('calc(-100px + -100px)', '-200px')); + test('minus sign', testValue('calc(-100px + -100px)', 'calc(-200px)')); test('minus sign (#2)', testValue('calc(-100px - -100px)', '0px')); - test('minus sign (#3)', testValue('calc(200px * -1)', '-200px')); + test('minus sign (#3)', testValue('calc(200px * -1)', 'calc(-200px)')); - test('minus sign (#4)', testValue('calc(200px / -1)', '-200px')); + test('minus sign (#4)', testValue('calc(200px / -1)', 'calc(-200px)')); }); describe('Whitespace', () => { diff --git a/test/integration/package-exports.test.mjs b/test/integration/package-exports.test.mjs index f2b648f..4ab1707 100644 --- a/test/integration/package-exports.test.mjs +++ b/test/integration/package-exports.test.mjs @@ -48,6 +48,7 @@ test('packed package exposes the standalone reducer with usable types', async () [ "import reduceCalc from 'postcss-calc/reduce';", "if (reduceCalc('calc(1px + 2px)') !== '3px') throw new Error('reducer failed');", + "if (reduceCalc('calc(1 - 2)', { unwrapSingleNegativeNumber: true }) !== '-1') throw new Error('bare negative option failed');", ].join('\n') ); run(process.execPath, [runtime], { cwd: fixture }); @@ -57,7 +58,7 @@ test('packed package exposes the standalone reducer with usable types', async () types, [ "import reduceCalc, { type ReduceCalcOptions } from 'postcss-calc/reduce';", - 'const options: ReduceCalcOptions = { precision: false };', + 'const options: ReduceCalcOptions = { precision: false, unwrapSingleNegativeNumber: true };', "const reduced: string = reduceCalc('calc(1px + 2px)', options);", 'void reduced;', ].join('\n') diff --git a/test/property/algebraic-laws.test.mjs b/test/property/algebraic-laws.test.mjs index b5fcd08..470af27 100644 --- a/test/property/algebraic-laws.test.mjs +++ b/test/property/algebraic-laws.test.mjs @@ -16,6 +16,11 @@ import { call, num, dim, ident } from '../../src/lib/node.js'; const NUM_RUNS = 500; const out = (n) => serialize(simplify(n), { precision: 10 }); +const scalarText = (text) => + text.startsWith('calc(') && text.endsWith(')') + ? text.slice('calc('.length, -1) + : text; +const numeric = (text) => Number.parseFloat(scalarText(text)); // Finite, non-zero numeric leaf — domain for most laws. const finiteNum = fc.integer({ min: -1000, max: 1000 }).map(num); @@ -67,7 +72,7 @@ describe('law: Abs Is', () => { fc.property(finiteLeaf, (x) => { const absStr = out(call('abs', [x])); // Output is `` or ``; never starts with `-`. - return !absStr.startsWith('-'); + return !scalarText(absStr).startsWith('-'); }), { numRuns: NUM_RUNS } ); @@ -79,7 +84,7 @@ describe('law: Abs Is', () => { const inner = out(call('sign', [x])); // sign(x) returns a bare number in {-1, 0, 1}; sign of that is the // same number. - const outer = out(call('sign', [num(Number.parseFloat(inner))])); + const outer = out(call('sign', [num(numeric(inner))])); return inner === outer; }), { numRuns: NUM_RUNS } @@ -90,8 +95,8 @@ describe('law: Abs Is', () => { fc.assert( fc.property(finiteNonzeroNum, (x) => { const negX = num(-x.value); - const lhs = Number.parseFloat(out(call('sign', [negX]))); - const rhs = -Number.parseFloat(out(call('sign', [x]))); + const lhs = numeric(out(call('sign', [negX]))); + const rhs = -numeric(out(call('sign', [x]))); return Object.is(lhs, rhs) || lhs === rhs; }), { numRuns: NUM_RUNS } @@ -101,8 +106,8 @@ describe('law: Abs Is', () => { test('law: abs(x) * sign(x) ≡ x — for finite numeric x', () => { fc.assert( fc.property(finiteNonzeroNum, (x) => { - const a = Number.parseFloat(out(call('abs', [x]))); - const s = Number.parseFloat(out(call('sign', [x]))); + const a = numeric(out(call('abs', [x]))); + const s = numeric(out(call('sign', [x]))); return a * s === x.value; }), { numRuns: NUM_RUNS } @@ -121,7 +126,7 @@ test('law: round is idempotent on the same step — round(round(x, B), B) ≡ ro const inner = call('round', [ident(strategy), x, b]); const once = out(inner); const twice = out( - call('round', [ident(strategy), num(Number.parseFloat(once)), b]) + call('round', [ident(strategy), num(numeric(once)), b]) ); return once === twice; } @@ -134,13 +139,9 @@ describe('law: Round Monotone', () => { test('law: round monotone in strategy — up ≥ nearest ≥ down', () => { fc.assert( fc.property(finiteNum, positiveNum, (x, b) => { - const up = Number.parseFloat(out(call('round', [ident('up'), x, b]))); - const nearest = Number.parseFloat( - out(call('round', [ident('nearest'), x, b])) - ); - const down = Number.parseFloat( - out(call('round', [ident('down'), x, b])) - ); + const up = numeric(out(call('round', [ident('up'), x, b]))); + const nearest = numeric(out(call('round', [ident('nearest'), x, b]))); + const down = numeric(out(call('round', [ident('down'), x, b]))); return up >= nearest && nearest >= down; }), { numRuns: NUM_RUNS } @@ -150,13 +151,9 @@ describe('law: Round Monotone', () => { test('law: round to-zero ∈ {up, down} and minimizes |result|', () => { fc.assert( fc.property(finiteNum, positiveNum, (x, b) => { - const up = Number.parseFloat(out(call('round', [ident('up'), x, b]))); - const down = Number.parseFloat( - out(call('round', [ident('down'), x, b])) - ); - const tz = Number.parseFloat( - out(call('round', [ident('to-zero'), x, b])) - ); + const up = numeric(out(call('round', [ident('up'), x, b]))); + const down = numeric(out(call('round', [ident('down'), x, b]))); + const tz = numeric(out(call('round', [ident('to-zero'), x, b]))); const inSet = tz === up || tz === down; const minimal = Math.abs(tz) <= Math.abs(up) && Math.abs(tz) <= Math.abs(down); @@ -173,9 +170,7 @@ describe('law: Round Monotone', () => { finiteNum, positiveNum, (strategy, x, b) => { - const r = Number.parseFloat( - out(call('round', [ident(strategy), x, b])) - ); + const r = numeric(out(call('round', [ident(strategy), x, b]))); const q = r / b.value; // Allow tiny FP drift: integer means q ≡ round(q) within EPSILON. return Math.abs(q - Math.round(q)) < 1e-9; @@ -192,9 +187,7 @@ describe('law: Round Monotone', () => { finiteNum, positiveNum, (strategy, x, b) => { - const r = Number.parseFloat( - out(call('round', [ident(strategy), x, b])) - ); + const r = numeric(out(call('round', [ident(strategy), x, b]))); return Math.abs(r - x.value) <= b.value + 1e-9; } ), @@ -205,13 +198,9 @@ describe('law: Round Monotone', () => { test('law: nearest minimizes |result − x| (with tie → upper)', () => { fc.assert( fc.property(finiteNum, positiveNum, (x, b) => { - const up = Number.parseFloat(out(call('round', [ident('up'), x, b]))); - const down = Number.parseFloat( - out(call('round', [ident('down'), x, b])) - ); - const nearest = Number.parseFloat( - out(call('round', [ident('nearest'), x, b])) - ); + const up = numeric(out(call('round', [ident('up'), x, b]))); + const down = numeric(out(call('round', [ident('down'), x, b]))); + const nearest = numeric(out(call('round', [ident('nearest'), x, b]))); const dUp = Math.abs(up - x.value); const dDown = Math.abs(down - x.value); return dUp <= dDown ? nearest === up : nearest === down; @@ -225,7 +214,7 @@ describe('law: Round Monotone', () => { test('law: mod range — 0 ≤ mod(x, B) < B (for B > 0, finite x)', () => { fc.assert( fc.property(finiteNum, positiveNum, (x, b) => { - const r = Number.parseFloat(out(call('mod', [x, b]))); + const r = numeric(out(call('mod', [x, b]))); return r >= 0 && r < b.value; }), { numRuns: NUM_RUNS } @@ -236,7 +225,7 @@ describe('law: Rem Range', () => { test('law: rem range — |rem(x, B)| < B (for B > 0, finite x)', () => { fc.assert( fc.property(finiteNum, positiveNum, (x, b) => { - const r = Number.parseFloat(out(call('rem', [x, b]))); + const r = numeric(out(call('rem', [x, b]))); return Math.abs(r) < b.value; }), { numRuns: NUM_RUNS } @@ -246,7 +235,7 @@ describe('law: Rem Range', () => { test('law: rem sign follows dividend — sign(rem(x, B)) ∈ {sign(x), 0}', () => { fc.assert( fc.property(finiteNum, positiveNum, (x, b) => { - const r = Number.parseFloat(out(call('rem', [x, b]))); + const r = numeric(out(call('rem', [x, b]))); if (r === 0) return true; return Math.sign(r) === Math.sign(x.value); }), @@ -257,10 +246,8 @@ describe('law: Rem Range', () => { test('law: mod periodicity — mod(x + B, B) ≡ mod(x, B)', () => { fc.assert( fc.property(finiteNum, positiveNum, (x, b) => { - const lhs = Number.parseFloat( - out(call('mod', [num(x.value + b.value), b])) - ); - const rhs = Number.parseFloat(out(call('mod', [x, b]))); + const lhs = numeric(out(call('mod', [num(x.value + b.value), b]))); + const rhs = numeric(out(call('mod', [x, b]))); return Math.abs(lhs - rhs) < 1e-9; }), { numRuns: NUM_RUNS } @@ -270,10 +257,8 @@ describe('law: Rem Range', () => { test('law: spec line 1017 — rem(A, B) ≡ A − round(to-zero, A, B)', () => { fc.assert( fc.property(finiteNum, positiveNum, (a, b) => { - const lhs = Number.parseFloat(out(call('rem', [a, b]))); - const r = Number.parseFloat( - out(call('round', [ident('to-zero'), a, b])) - ); + const lhs = numeric(out(call('rem', [a, b]))); + const r = numeric(out(call('round', [ident('to-zero'), a, b]))); const rhs = a.value - r; return Math.abs(lhs - rhs) < 1e-9; }), @@ -286,8 +271,8 @@ describe('law: Rem Range', () => { // For B > 0 this reduces to mod(A, B) = A − round(down, A, B). fc.assert( fc.property(finiteNum, positiveNum, (a, b) => { - const lhs = Number.parseFloat(out(call('mod', [a, b]))); - const r = Number.parseFloat(out(call('round', [ident('down'), a, b]))); + const lhs = numeric(out(call('mod', [a, b]))); + const r = numeric(out(call('round', [ident('down'), a, b]))); const rhs = a.value - r; return Math.abs(lhs - rhs) < 1e-9; }), @@ -305,10 +290,10 @@ test('metamorphic: round scales — round(k·x, k·B) ≡ k·round(x, B), k > 0' fc.integer({ min: 1, max: 100 }), fc.integer({ min: 1, max: 10 }), (strategy, xRaw, bRaw, k) => { - const lhs = Number.parseFloat( + const lhs = numeric( out(call('round', [ident(strategy), num(k * xRaw), num(k * bRaw)])) ); - const inner = Number.parseFloat( + const inner = numeric( out(call('round', [ident(strategy), num(xRaw), num(bRaw)])) ); const rhs = k * inner; @@ -346,8 +331,8 @@ const CURATED_ANGLES = [ describe('law: Sin Is', () => { test('law: sin is odd — sin(-x) ≡ -sin(x) for curated angles', () => { for (const x of CURATED_ANGLES) { - const lhs = Number.parseFloat(out(call('sin', [num(-x)]))); - const rhs = -Number.parseFloat(out(call('sin', [num(x)]))); + const lhs = numeric(out(call('sin', [num(-x)]))); + const rhs = -numeric(out(call('sin', [num(x)]))); if (Math.abs(lhs - rhs) > 1e-9) { throw new Error(`sin(-${x}) (${lhs}) ≠ -sin(${x}) (${rhs})`); } @@ -356,8 +341,8 @@ describe('law: Sin Is', () => { test('law: cos is even — cos(-x) ≡ cos(x) for curated angles', () => { for (const x of CURATED_ANGLES) { - const lhs = Number.parseFloat(out(call('cos', [num(-x)]))); - const rhs = Number.parseFloat(out(call('cos', [num(x)]))); + const lhs = numeric(out(call('cos', [num(-x)]))); + const rhs = numeric(out(call('cos', [num(x)]))); if (Math.abs(lhs - rhs) > 1e-9) { throw new Error(`cos(-${x}) (${lhs}) ≠ cos(${x}) (${rhs})`); } @@ -375,8 +360,8 @@ describe('law: Sin Is', () => { test('law: sin² + cos² ≡ 1 over a finite range (away from asymptotes)', () => { fc.assert( fc.property(finiteFloat, (x) => { - const s = Number.parseFloat(out(call('sin', [num(x)]))); - const c = Number.parseFloat(out(call('cos', [num(x)]))); + const s = numeric(out(call('sin', [num(x)]))); + const c = numeric(out(call('cos', [num(x)]))); return Math.abs(s * s + c * c - 1) < 1e-9; }), { numRuns: NUM_RUNS } @@ -395,8 +380,8 @@ describe('law: Sin Is', () => { }); fc.assert( fc.property(principalRange, (x) => { - const s = Number.parseFloat(out(call('sin', [num(x)]))); - const aDeg = Number.parseFloat(out(call('asin', [num(s)]))); + const s = numeric(out(call('sin', [num(x)]))); + const aDeg = numeric(out(call('asin', [num(s)]))); const aRad = (aDeg * Math.PI) / 180; return Math.abs(aRad - x) < 1e-6; }), @@ -408,11 +393,9 @@ describe('law: Sin Is', () => { fc.assert( fc.property(fc.float({ min: -179, max: 180, noNaN: true }), (degRaw) => { const theta = (degRaw * Math.PI) / 180; - const s = Number.parseFloat(out(call('sin', [num(theta)]))); - const c = Number.parseFloat(out(call('cos', [num(theta)]))); - const recovered = Number.parseFloat( - out(call('atan2', [num(s), num(c)])) - ); + const s = numeric(out(call('sin', [num(theta)]))); + const c = numeric(out(call('cos', [num(theta)]))); + const recovered = numeric(out(call('atan2', [num(s), num(c)]))); return Math.abs(recovered - degRaw) < 1e-6; }), { numRuns: NUM_RUNS } @@ -427,10 +410,8 @@ describe('law: Sin Is', () => { fc.integer({ min: 1, max: 100 }), (y, x, k) => { if (x === 0 && y === 0) return true; // atan2(0,0) is degenerate - const lhs = Number.parseFloat( - out(call('atan2', [num(k * y), num(k * x)])) - ); - const rhs = Number.parseFloat(out(call('atan2', [num(y), num(x)]))); + const lhs = numeric(out(call('atan2', [num(k * y), num(k * x)]))); + const rhs = numeric(out(call('atan2', [num(y), num(x)]))); return Math.abs(lhs - rhs) < 1e-9; } ), @@ -464,9 +445,7 @@ describe('law: Pow(x 0', () => { test('law: sqrt(pow(x, 2)) ≡ abs(x) for finite x', () => { fc.assert( fc.property(fc.integer({ min: -100, max: 100 }), (v) => { - const lhs = Number.parseFloat( - out(call('sqrt', [call('pow', [num(v), num(2)])])) - ); + const lhs = numeric(out(call('sqrt', [call('pow', [num(v), num(2)])]))); const rhs = Math.abs(v); return Math.abs(lhs - rhs) < 1e-9; }), @@ -477,10 +456,8 @@ describe('law: Pow(x 0', () => { test('law: log(exp(x)) ≡ x for finite x within precision', () => { fc.assert( fc.property(fc.float({ min: -50, max: 50, noNaN: true }), (v) => { - const lhs = Number.parseFloat( - out(call('log', [call('exp', [num(v)])])) - ); - return Math.abs(lhs - v) < 1e-6; + const lhs = numeric(out(call('log', [call('exp', [num(v)])]))); + return Math.abs(v) < 1e-12 || Math.abs(lhs - v) < 1e-6; }), { numRuns: NUM_RUNS } ); @@ -495,9 +472,7 @@ describe('law: Pow(x 0', () => { noNaN: true, }), (v) => { - const lhs = Number.parseFloat( - out(call('exp', [call('log', [num(v)])])) - ); + const lhs = numeric(out(call('exp', [call('log', [num(v)])]))); return Math.abs((lhs - v) / v) < 1e-6; } ), diff --git a/test/property/differential.test.mjs b/test/property/differential.test.mjs index 3c11031..53816c6 100644 --- a/test/property/differential.test.mjs +++ b/test/property/differential.test.mjs @@ -50,6 +50,9 @@ function canonicalize(s) { try { return serialize(simplify(parse(tokenize(s))), { precision: COMPARE_PRECISION, + // Differential comparison ignores the wrapper-only distinction. The + // production serializer keeps it for range-safe value output. + unwrapSingleNegativeNumber: true, }); } catch { return null; @@ -84,6 +87,7 @@ function canonicalizeLoose(s) { try { return serialize(simplify(parse(tokenize(s))), { precision: COMPARE_PRECISION_LOOSE, + unwrapSingleNegativeNumber: true, }); } catch { return null; @@ -105,7 +109,7 @@ function checkAgreement(input) { if (canonicalTheirs === null) { return true; } - return ours === canonicalTheirs; + return canonicalize(ours) === canonicalTheirs; } function checkAgreementLoose(input) { const ours = ourOutLoose(input); @@ -114,7 +118,7 @@ function checkAgreementLoose(input) { if (ours === theirs) return true; const canonicalTheirs = canonicalizeLoose(theirs); if (canonicalTheirs === null) return true; - return ours === canonicalTheirs; + return canonicalizeLoose(ours) === canonicalTheirs; } test('differential: our simplifier agrees with csstools (canonicalized)', () => { fc.assert(fc.property(inputArb, checkAgreement), { numRuns: NUM_RUNS }); diff --git a/test/property/naive-oracle.test.mjs b/test/property/naive-oracle.test.mjs index a59ff7a..3a55799 100644 --- a/test/property/naive-oracle.test.mjs +++ b/test/property/naive-oracle.test.mjs @@ -15,6 +15,11 @@ import { parse } from '../../src/lib/parser.js'; import { simplify } from '../../src/lib/simplify.js'; import { serialize } from '../../src/lib/serialize.js'; const out = (s) => serialize(simplify(parse(tokenize(s))), { precision: 10 }); +const scalarText = (text) => + text.startsWith('calc(') && text.endsWith(')') + ? text.slice('calc('.length, -1) + : text; +const numeric = (text) => Number.parseFloat(scalarText(text)); // --- Naive reference impls ---------------------------------------------- // // These use a *different* algorithmic shape from `simplify.ts`: @@ -120,9 +125,7 @@ for (const row of rows) { for (const strategy of STRATEGIES) { test(`oracle: round(${strategy}, ${row.a}, ${row.b}) [${row.desc}]`, () => { const expected = naiveRound(strategy, row.a, row.b); - const got = Number.parseFloat( - out(`round(${strategy}, ${row.a}, ${row.b})`) - ); + const got = numeric(out(`round(${strategy}, ${row.a}, ${row.b})`)); // NaN === NaN check via Object.is. if (Number.isNaN(expected)) { assert.ok(Number.isNaN(got), `expected NaN, got ${got}`); @@ -142,7 +145,7 @@ for (const row of rows) { if (row.b !== 0 && Math.abs(row.a / row.b) > 100000) continue; test(`oracle: mod(${row.a}, ${row.b}) [${row.desc}]`, () => { const expected = naiveMod(row.a, row.b); - const got = Number.parseFloat(out(`mod(${row.a}, ${row.b})`)); + const got = numeric(out(`mod(${row.a}, ${row.b})`)); if (Number.isNaN(expected)) { assert.ok(Number.isNaN(got), `expected NaN, got ${got}`); } else { @@ -154,7 +157,7 @@ for (const row of rows) { }); test(`oracle: rem(${row.a}, ${row.b}) [${row.desc}]`, () => { const expected = naiveRem(row.a, row.b); - const got = Number.parseFloat(out(`rem(${row.a}, ${row.b})`)); + const got = numeric(out(`rem(${row.a}, ${row.b})`)); if (Number.isNaN(expected)) { assert.ok(Number.isNaN(got), `expected NaN, got ${got}`); } else { @@ -170,14 +173,14 @@ const SIGN_INPUTS = [0, -0, 1, -1, 5, -5, 100, -100, 0.0001, -0.0001]; for (const a of SIGN_INPUTS) { test(`oracle: abs(${a})`, () => { const expected = Math.abs(a); - const got = Number.parseFloat(out(`abs(${a})`)); + const got = numeric(out(`abs(${a})`)); assert.equal(got, expected); }); test(`oracle: sign(${a})`, () => { // Math.sign(-0) === -0; we serialize that as "0". Compare via // `+got === +expected` to fold ±0. const expected = Math.sign(a); - const got = Number.parseFloat(out(`sign(${a})`)); + const got = numeric(out(`sign(${a})`)); assert.ok(+got === +expected, `naive=${expected}, prod=${got}`); }); } @@ -207,7 +210,7 @@ const TRIG_RADIAN_INPUTS = [ for (const x of TRIG_RADIAN_INPUTS) { test(`oracle: sin(${x}) [radians]`, () => { const expected = Math.sin(x); - const got = Number.parseFloat(out(`sin(${x})`)); + const got = numeric(out(`sin(${x})`)); assert.ok( Math.abs(got - expected) < 1e-9, `naive=${expected}, prod=${got}` @@ -215,7 +218,7 @@ for (const x of TRIG_RADIAN_INPUTS) { }); test(`oracle: cos(${x}) [radians]`, () => { const expected = Math.cos(x); - const got = Number.parseFloat(out(`cos(${x})`)); + const got = numeric(out(`cos(${x})`)); assert.ok( Math.abs(got - expected) < 1e-9, `naive=${expected}, prod=${got}` @@ -223,7 +226,7 @@ for (const x of TRIG_RADIAN_INPUTS) { }); test(`oracle: tan(${x}) [radians, may be near-asymptote]`, () => { const expected = Math.tan(x); - const got = Number.parseFloat(out(`tan(${x})`)); + const got = numeric(out(`tan(${x})`)); // tan diverges near ±π/2; compare via relative error there. if (Math.abs(expected) > 1e6) { // Both sides should be huge and the same sign — exact match @@ -243,7 +246,7 @@ const INVERSE_TRIG_NUMBER_INPUTS = [-1, -0.5, 0, 0.25, 0.5, 0.75, 1]; for (const x of INVERSE_TRIG_NUMBER_INPUTS) { test(`oracle: asin(${x})`, () => { const expectedDeg = (Math.asin(x) * 180) / Math.PI; - const got = Number.parseFloat(out(`asin(${x})`)); + const got = numeric(out(`asin(${x})`)); assert.ok( Math.abs(got - expectedDeg) < 1e-9, `naive=${expectedDeg}deg, prod=${got}deg` @@ -251,7 +254,7 @@ for (const x of INVERSE_TRIG_NUMBER_INPUTS) { }); test(`oracle: acos(${x})`, () => { const expectedDeg = (Math.acos(x) * 180) / Math.PI; - const got = Number.parseFloat(out(`acos(${x})`)); + const got = numeric(out(`acos(${x})`)); assert.ok( Math.abs(got - expectedDeg) < 1e-9, `naive=${expectedDeg}deg, prod=${got}deg` @@ -262,7 +265,7 @@ const ATAN_INPUTS = [-1000, -1, -0.5, 0, 0.5, 1, 1000]; for (const x of ATAN_INPUTS) { test(`oracle: atan(${x})`, () => { const expectedDeg = (Math.atan(x) * 180) / Math.PI; - const got = Number.parseFloat(out(`atan(${x})`)); + const got = numeric(out(`atan(${x})`)); assert.ok( Math.abs(got - expectedDeg) < 1e-9, `naive=${expectedDeg}deg, prod=${got}deg` @@ -287,7 +290,7 @@ const ATAN2_INPUTS = [ for (const [y, x] of ATAN2_INPUTS) { test(`oracle: atan2(${y}, ${x})`, () => { const expectedDeg = (Math.atan2(y, x) * 180) / Math.PI; - const got = Number.parseFloat(out(`atan2(${y}, ${x})`)); + const got = numeric(out(`atan2(${y}, ${x})`)); assert.ok( Math.abs(got - expectedDeg) < 1e-9, `naive=${expectedDeg}deg, prod=${got}deg` @@ -313,7 +316,7 @@ const POW_INPUTS = [ for (const [a, b] of POW_INPUTS) { test(`oracle: pow(${a}, ${b})`, () => { const expected = Math.pow(a, b); - const got = Number.parseFloat(out(`pow(${a}, ${b})`)); + const got = numeric(out(`pow(${a}, ${b})`)); assert.ok( Math.abs(got - expected) < 1e-9, `naive=${expected}, prod=${got}` @@ -324,7 +327,7 @@ const SQRT_INPUTS = [0, 1, 2, 4, 9, 16, 25, 100, 0.25]; for (const x of SQRT_INPUTS) { test(`oracle: sqrt(${x})`, () => { const expected = Math.sqrt(x); - const got = Number.parseFloat(out(`sqrt(${x})`)); + const got = numeric(out(`sqrt(${x})`)); assert.ok( Math.abs(got - expected) < 1e-9, `naive=${expected}, prod=${got}` @@ -335,7 +338,7 @@ const EXP_INPUTS = [-2, -1, 0, 0.5, 1, 2, 5]; for (const x of EXP_INPUTS) { test(`oracle: exp(${x})`, () => { const expected = Math.exp(x); - const got = Number.parseFloat(out(`exp(${x})`)); + const got = numeric(out(`exp(${x})`)); assert.ok( Math.abs(got - expected) < 1e-6, `naive=${expected}, prod=${got}` @@ -346,7 +349,7 @@ const LOG1_INPUTS = [1, 2, Math.E, 10, 100, 0.5]; for (const x of LOG1_INPUTS) { test(`oracle: log(${x})`, () => { const expected = Math.log(x); - const got = Number.parseFloat(out(`log(${x})`)); + const got = numeric(out(`log(${x})`)); assert.ok( Math.abs(got - expected) < 1e-9, `naive=${expected}, prod=${got}` @@ -363,7 +366,7 @@ const LOG2_INPUTS = [ for (const [a, b] of LOG2_INPUTS) { test(`oracle: log(${a}, ${b})`, () => { const expected = Math.log(a) / Math.log(b); - const got = Number.parseFloat(out(`log(${a}, ${b})`)); + const got = numeric(out(`log(${a}, ${b})`)); assert.ok( Math.abs(got - expected) < 1e-9, `naive=${expected}, prod=${got}` @@ -382,7 +385,7 @@ const HYPOT_INPUTS = [ for (const args of HYPOT_INPUTS) { test(`oracle: hypot(${args.join(', ')})`, () => { const expected = Math.hypot(...args); - const got = Number.parseFloat(out(`hypot(${args.join(', ')})`)); + const got = numeric(out(`hypot(${args.join(', ')})`)); assert.ok( Math.abs(got - expected) < 1e-9, `naive=${expected}, prod=${got}` diff --git a/test/unit/plugin.test.mjs b/test/unit/plugin.test.mjs index 18dd54f..f6f4fdf 100644 --- a/test/unit/plugin.test.mjs +++ b/test/unit/plugin.test.mjs @@ -46,7 +46,21 @@ describe('plugin: basic pipeline', () => { const { css } = await process( 'a{a:calc(1px + 2px);b:calc(10% - 2%);c:calc(1 / 4);d:calc(-2px + 1px);e:calc(1PX + 2PX)}' ); - assert.equal(css, 'a{a:3px;b:8%;c:.25;d:-1px;e:3px}'); + assert.equal(css, 'a{a:3px;b:8%;c:.25;d:calc(-1px);e:3px}'); + }); + + test('plugin: negative scalar results retain calc()', async () => { + const { css } = await process('a{width:calc(5px - 10px)}'); + assert.equal(css, 'a{width:calc(-5px)}'); + }); + + test('plugin: rounded negative floating-point noise does not retain calc()', async () => { + const { css } = await process('a{width:calc(cos(270deg) * 100px)}'); + assert.equal(css, 'a{width:0px}'); + const unrounded = await process('a{width:calc(cos(270deg) * 100px)}', { + precision: false, + }); + assert.equal(unrounded.css, 'a{width:calc(-1.8369701987210297e-14px)}'); }); test('plugin: multiple calcs in one value', async () => { @@ -143,6 +157,14 @@ test('plugin: no warning when expression fully resolves', async () => { assert.equal(warnings.length, 0); }); +test('plugin: no warning when a negative expression fully resolves', async () => { + const { css, warnings } = await process('a{b:calc(1px - 2px)}', { + warnWhenCannotResolve: true, + }); + assert.equal(css, 'a{b:calc(-1px)}'); + assert.equal(warnings.length, 0); +}); + // --- mediaQueries -------------------------------------------------------- describe('plugin: MediaQueries', () => { test('plugin: mediaQueries reduces calc in @media params', async () => { @@ -280,6 +302,19 @@ describe('plugin: option combinations', () => { }); }); + test('plugin: selectors serialize negative scalars without calc()', async () => { + const { css } = await process('a:nth-child(calc(1 - 2)) { b: c }', { + selectors: true, + }); + assert.equal(css, 'a:nth-child(-1) { b: c }'); + }); + + test('plugin: negative selector transformations are idempotent', async () => { + await assertIdempotent('a:nth-child(calc(1 - 2)) { b: c }', { + selectors: true, + }); + }); + test('plugin: onParseError does not fire for fully-resolved inputs', async () => { const errors = []; await process('a{b:calc(1px + 2px)}', { diff --git a/test/unit/reduceCalc.test.mjs b/test/unit/reduceCalc.test.mjs index 7017ef0..bd3dda1 100644 --- a/test/unit/reduceCalc.test.mjs +++ b/test/unit/reduceCalc.test.mjs @@ -48,10 +48,36 @@ describe('reduceCalc: basic pipeline', () => { assert.equal(reduceCalc('calc(1px + 2px)'), '3px'); assert.equal(reduceCalc('calc(10% - 2%)'), '8%'); assert.equal(reduceCalc('calc(1 / 4)'), '.25'); - assert.equal(reduceCalc('calc(-2px + 1px)'), '-1px'); + assert.equal(reduceCalc('calc(-2px + 1px)'), 'calc(-1px)'); assert.equal(reduceCalc('calc(1PX + 2PX)'), '3px'); }); + test('reduceCalc: negative scalar results retain calc()', () => { + assert.equal(reduceCalc('calc(5px - 10px)'), 'calc(-5px)'); + assert.equal(reduceCalc('calc(5% - 10%)'), 'calc(-5%)'); + }); + + test('reduceCalc: rounded negative floating-point noise does not retain calc()', () => { + assert.equal(reduceCalc('calc(cos(270deg) * 100px)'), '0px'); + assert.equal( + reduceCalc('calc(cos(270deg) * 100px)', { precision: false }), + 'calc(-1.8369701987210297e-14px)' + ); + }); + + test('reduceCalc: unwrapSingleNegativeNumber controls negative scalar serialization', () => { + assert.equal( + reduceCalc('a:nth-child(calc(1 - 2))', { + unwrapSingleNegativeNumber: true, + }), + 'a:nth-child(-1)' + ); + assert.equal( + reduceCalc('calc(1 - 2)', { unwrapSingleNegativeNumber: true }), + '-1' + ); + }); + test('reduceCalc: multiple calcs in one value', () => { assert.equal(reduceCalc('calc(1px + 1px) calc(2px + 2px)'), '2px 4px'); }); @@ -221,6 +247,21 @@ test('reduceCalc: no warning when expression fully resolves', () => { assert.equal(warnings.length, 0); }); +test('reduceCalc: no warning when a negative expression fully resolves', () => { + const { output, warnings } = reduceWithWarnings('calc(1px - 2px)', { + warnWhenCannotResolve: true, + }); + assert.equal(output, 'calc(-1px)'); + assert.equal(warnings.length, 0); +}); + +test('reduceCalc: warns for an unresolved supported math call', () => { + const { warnings } = reduceWithWarnings('calc(abs(var(--x)))', { + warnWhenCannotResolve: true, + }); + assert.equal(warnings.length, 1); +}); + // --- mediaQueries / selectors (value strings the plugin would pass) ------ describe('reduceCalc: media query params', () => { test('reduceCalc: reduces calc in a media-query param string', () => { diff --git a/test/unit/serialize.test.mjs b/test/unit/serialize.test.mjs index c5ee6bf..0492d7e 100644 --- a/test/unit/serialize.test.mjs +++ b/test/unit/serialize.test.mjs @@ -9,11 +9,11 @@ import { num, dim, mkSum, mkProduct } from '../../src/lib/node.js'; // value — no wrapper needed. describe('serialize: Single Number', () => { - test('serialize: single number — no calc wrapper', () => { + test('serialize: single number — no calc() function', () => { assert.equal(serialize(num(42)), '42'); }); - test('serialize: single dimension — no calc wrapper', () => { + test('serialize: single dimension — no calc() function', () => { assert.equal(serialize(dim(10, 'px')), '10px'); }); @@ -64,13 +64,13 @@ describe('serialize: Single Number', () => { assert.equal(serialize(ast), 'calc((1 + 2) * 3)'); }); - test('serialize: negative Dim via signed leaf → bare -Xpx', () => { + test('serialize: negative Dim via signed leaf → calc(-Xpx)', () => { // Negatives live directly in the Dim value. The constructor helper // `dim(-1, 'px')` returns a Dim with value -1, no Sum wrapper. - assert.equal(serialize(dim(-1, 'px')), '-1px'); + assert.equal(serialize(dim(-1, 'px')), 'calc(-1px)'); }); - test('serialize: single-term Sum with opaque gets calc wrapper', () => { + test('serialize: single-term Sum with opaque gets calc() function', () => { // `-var(--x)` needs calc() so the leading minus isn't ambiguous. const ast = mkSum([ { @@ -99,7 +99,7 @@ describe('serialize: Single Number', () => { test('serialize: omits the leading zero from fractional numbers', () => { assert.equal(serialize(num(0.5)), '.5'); - assert.equal(serialize(num(-0.000001)), '-.000001'); + assert.equal(serialize(num(-0.000001)), 'calc(-.000001)'); assert.equal(serialize(dim(0.25, 'px')), '.25px'); assert.equal(serialize(num(0)), '0'); assert.equal(serialize(num(1e-7)), '1e-7'); @@ -138,9 +138,8 @@ describe('serialize: mutation-targeted tests', () => { assert.equal(serialize(ast), 'calc(5px - 2em)'); }); - test('serialize: negative leading Num serializes without calc() wrap', () => { - // Top-level bare Num(-5) — no calc() needed. - assert.equal(serialize({ type: 'Num', value: -5 }), '-5'); + test('serialize: negative leading Num keeps calc() function', () => { + assert.equal(serialize(num(-5)), 'calc(-5)'); }); test('serialize: single-term Sum with sign=-1 and opaque Call → calc(-call)', () => { diff --git a/test/unit/simplify/atan2.test.mjs b/test/unit/simplify/atan2.test.mjs index 962b576..87638cf 100644 --- a/test/unit/simplify/atan2.test.mjs +++ b/test/unit/simplify/atan2.test.mjs @@ -22,7 +22,7 @@ describe('atan2: 1 0', () => { }); test('atan2: (-1, 0) → -90deg', () => { - assert.equal(out('atan2(-1, 0)'), '-90deg'); + assert.equal(out('atan2(-1, 0)'), 'calc(-90deg)'); }); test('atan2: (1, 1) → 45deg', () => { @@ -30,7 +30,7 @@ describe('atan2: 1 0', () => { }); test('atan2: (-1, 1) → -45deg', () => { - assert.equal(out('atan2(-1, 1)'), '-45deg'); + assert.equal(out('atan2(-1, 1)'), 'calc(-45deg)'); }); test('atan2: (1, -1) → 135deg', () => { @@ -38,7 +38,7 @@ describe('atan2: 1 0', () => { }); test('atan2: (-1, -1) → -135deg', () => { - assert.equal(out('atan2(-1, -1)'), '-135deg'); + assert.equal(out('atan2(-1, -1)'), 'calc(-135deg)'); }); test('atan2: same-unit dim args fold (1px, 1px) → 45deg', () => { @@ -56,7 +56,7 @@ describe('atan2: 1 0', () => { }); test('atan2: spec table (-infinity, -infinity) → -135deg', () => { - assert.equal(out('atan2(-infinity, -infinity)'), '-135deg'); + assert.equal(out('atan2(-infinity, -infinity)'), 'calc(-135deg)'); }); test('atan2: same-base angle args (1deg, 1deg) → 45deg', () => { diff --git a/test/unit/simplify/general.test.mjs b/test/unit/simplify/general.test.mjs index 47886ea..1833d81 100644 --- a/test/unit/simplify/general.test.mjs +++ b/test/unit/simplify/general.test.mjs @@ -238,12 +238,12 @@ test('spec §10.3 line 1004: mod(18px, 5px) === 3px', () => { describe('spec §10.3 line 1005: Mod(-140deg -90deg', () => { test('spec §10.3 line 1005: mod(-140deg, -90deg) === -50deg', () => { - assert.equal(out('mod(-140deg, -90deg)'), '-50deg'); + assert.equal(out('mod(-140deg, -90deg)'), 'calc(-50deg)'); }); test('spec §10.3 line 1007: rem === mod when both args same sign', () => { assert.equal(out('rem(18px, 5px)'), '3px'); - assert.equal(out('rem(-140deg, -90deg)'), '-50deg'); + assert.equal(out('rem(-140deg, -90deg)'), 'calc(-50deg)'); }); test('spec §10.3 line 1011: mod(-18px, 5px) === 2px', () => { @@ -251,11 +251,11 @@ describe('spec §10.3 line 1005: Mod(-140deg -90deg', () => { }); test('spec §10.3 line 1012: rem(-18px, 5px) === -3px', () => { - assert.equal(out('rem(-18px, 5px)'), '-3px'); + assert.equal(out('rem(-18px, 5px)'), 'calc(-3px)'); }); test('spec §10.3 line 1014: mod(140deg, -90deg) === -40deg', () => { - assert.equal(out('mod(140deg, -90deg)'), '-40deg'); + assert.equal(out('mod(140deg, -90deg)'), 'calc(-40deg)'); }); test('spec §10.3 line 1014: rem(140deg, -90deg) === 50deg', () => { @@ -266,7 +266,7 @@ describe('spec §10.3 line 1005: Mod(-140deg -90deg', () => { // 15 is exactly between 10 and 20; spec says upper wins. assert.equal(out('round(15, 10)'), '20'); // -15 between -20 and -10; upper (+∞-ward) is -10. - assert.equal(out('round(-15, 10)'), '-10'); + assert.equal(out('round(-15, 10)'), 'calc(-10)'); }); test('spec §10.3 line 991: B defaults to 1 only when A is ', () => { @@ -341,7 +341,7 @@ describe('spec §10.3 line 1005: Mod(-140deg -90deg', () => { test('spec §10.3.1 line 1039: rem(A, infinity) returns A regardless of sign', () => { assert.equal(out('rem(5, infinity)'), '5'); - assert.equal(out('rem(-5, infinity)'), '-5'); + assert.equal(out('rem(-5, infinity)'), 'calc(-5)'); assert.equal(out('rem(0, infinity)'), '0'); }); @@ -355,8 +355,8 @@ describe('spec §10.3 line 1005: Mod(-140deg -90deg', () => { test('spec §10.6 line 1146: sign(A) always returns ', () => { // Even when input is a dimension, the result is a bare number. - assert.equal(out('sign(-5)'), '-1'); - assert.equal(out('sign(-5px)'), '-1'); + assert.equal(out('sign(-5)'), 'calc(-1)'); + assert.equal(out('sign(-5px)'), 'calc(-1)'); assert.equal(out('sign(5em)'), '1'); assert.equal(out('sign(0deg)'), '0'); }); @@ -374,16 +374,16 @@ describe('CSS keywords case-insensitive: Rounding-strategy Idents', () => { // CSS idents are case-insensitive by default; our toLowerCase honors that. assert.equal(out('round(UP, 11, 10)'), '20'); assert.equal(out('round(Down, 19, 10)'), '10'); - assert.equal(out('round(TO-ZERO, -19, 10)'), '-10'); + assert.equal(out('round(TO-ZERO, -19, 10)'), 'calc(-10)'); assert.equal(out('round(Nearest, 14, 10)'), '10'); }); test('CSS function names case-insensitive: ROUND, MOD, REM, ABS, SIGN', () => { assert.equal(out('ROUND(15, 10)'), '20'); assert.equal(out('MOD(18, 5)'), '3'); - assert.equal(out('REM(-18, 5)'), '-3'); + assert.equal(out('REM(-18, 5)'), 'calc(-3)'); assert.equal(out('ABS(-5)'), '5'); - assert.equal(out('SIGN(-5)'), '-1'); + assert.equal(out('SIGN(-5)'), 'calc(-1)'); }); }); @@ -398,7 +398,7 @@ test('boundary: round at exact tie midpoints across signs', () => { assert.equal(out('round(5, 10)'), '10'); // {0, 10}, tie → upper assert.equal(out('round(-5, 10)'), '0'); // {-10, 0}, tie → upper (= 0) assert.equal(out('round(15, 10)'), '20'); - assert.equal(out('round(-15, 10)'), '-10'); + assert.equal(out('round(-15, 10)'), 'calc(-10)'); assert.equal(out('round(25, 10)'), '30'); assert.equal(out('round(0.5, 1)'), '1'); assert.equal(out('round(-0.5, 1)'), '0'); @@ -411,13 +411,13 @@ describe('boundary: Round Just-below-tie', () => { assert.equal(out('round(4.9, 10)'), '0'); assert.equal(out('round(5.1, 10)'), '10'); assert.equal(out('round(-4.9, 10)'), '0'); - assert.equal(out('round(-5.1, 10)'), '-10'); + assert.equal(out('round(-5.1, 10)'), 'calc(-10)'); }); test('boundary: round on exact multiple preserves value', () => { assert.equal(out('round(20, 10)'), '20'); assert.equal(out('round(0, 10)'), '0'); - assert.equal(out('round(-30, 10)'), '-30'); + assert.equal(out('round(-30, 10)'), 'calc(-30)'); assert.equal(out('round(up, 20, 10)'), '20'); assert.equal(out('round(down, 20, 10)'), '20'); assert.equal(out('round(to-zero, 20, 10)'), '20'); @@ -445,7 +445,7 @@ describe('boundary: Round Just-below-tie', () => { assert.equal(out('round(999999, 100)'), '1000000'); // -999999 / 100 = -9999.99: candidates {-1000000, -999900}. Distances: // |-999999 - -1000000| = 1, |-999900 - -999999| = 99 → lower (-1000000) closer. - assert.equal(out('round(-999999, 100)'), '-1000000'); + assert.equal(out('round(-999999, 100)'), 'calc(-1000000)'); }); test('boundary: abs on -0 collapses to 0 (mkSum drop-zero)', () => { @@ -468,7 +468,7 @@ describe('boundary: Round Just-below-tie', () => { test('boundary: sign on infinity / NaN', () => { assert.equal(out('sign(infinity)'), '1'); - assert.equal(out('sign(calc(0 - infinity))'), '-1'); + assert.equal(out('sign(calc(0 - infinity))'), 'calc(-1)'); assert.equal(out('sign(NaN)'), 'calc(NaN)'); }); diff --git a/test/unit/simplify/inverse-trig.test.mjs b/test/unit/simplify/inverse-trig.test.mjs index 9cbce35..153ac2a 100644 --- a/test/unit/simplify/inverse-trig.test.mjs +++ b/test/unit/simplify/inverse-trig.test.mjs @@ -16,7 +16,7 @@ describe('asin: 1 →', () => { }); test('asin: -1 → -90deg', () => { - assert.equal(out('asin(-1)'), '-90deg'); + assert.equal(out('asin(-1)'), 'calc(-90deg)'); }); test('asin: 0.5 → 30deg (Math.asin(0.5)*180/π = 30.0000... rounds to 30)', () => { @@ -48,7 +48,7 @@ describe('atan: 0 →', () => { }); test('atan: -1 → -45deg', () => { - assert.equal(out('atan(-1)'), '-45deg'); + assert.equal(out('atan(-1)'), 'calc(-45deg)'); }); test('atan: infinity → 90deg (Math.atan(Infinity) = π/2 exactly)', () => { @@ -56,7 +56,7 @@ describe('atan: 0 →', () => { }); test('atan: -infinity → -90deg', () => { - assert.equal(out('atan(-infinity)'), '-90deg'); + assert.equal(out('atan(-infinity)'), 'calc(-90deg)'); }); }); diff --git a/test/unit/simplify/mod-rem.test.mjs b/test/unit/simplify/mod-rem.test.mjs index ef374c0..da9d980 100644 --- a/test/unit/simplify/mod-rem.test.mjs +++ b/test/unit/simplify/mod-rem.test.mjs @@ -13,11 +13,11 @@ test('rem: positive A and positive B (== mod when same sign)', () => { }); test('mod: negative A and negative B (spec example -140deg, -90deg → -50deg)', () => { - assert.equal(out('mod(-140deg, -90deg)'), '-50deg'); + assert.equal(out('mod(-140deg, -90deg)'), 'calc(-50deg)'); }); test('rem: negative A and negative B (== mod when same sign)', () => { - assert.equal(out('rem(-140deg, -90deg)'), '-50deg'); + assert.equal(out('rem(-140deg, -90deg)'), 'calc(-50deg)'); }); test('mod: negative A and positive B (spec example -18px, 5px → 2px — sign of B)', () => { @@ -25,11 +25,11 @@ test('mod: negative A and positive B (spec example -18px, 5px → 2px — sign o }); test('rem: negative A and positive B (spec example -18px, 5px → -3px — sign of A)', () => { - assert.equal(out('rem(-18px, 5px)'), '-3px'); + assert.equal(out('rem(-18px, 5px)'), 'calc(-3px)'); }); test('mod: positive A and negative B (spec example 140deg, -90deg → -40deg)', () => { - assert.equal(out('mod(140deg, -90deg)'), '-40deg'); + assert.equal(out('mod(140deg, -90deg)'), 'calc(-40deg)'); }); test('rem: positive A and negative B (spec example 140deg, -90deg → 50deg)', () => { @@ -97,7 +97,7 @@ test('mod: B infinite, A same sign → A unchanged', () => { test('rem: B infinite → A unchanged', () => { assert.equal(out('rem(5, infinity)'), '5'); - assert.equal(out('rem(-5, infinity)'), '-5'); + assert.equal(out('rem(-5, infinity)'), 'calc(-5)'); }); describe('mod: B Infinite', () => { diff --git a/test/unit/simplify/pow.test.mjs b/test/unit/simplify/pow.test.mjs index ba63f7e..5940fa5 100644 --- a/test/unit/simplify/pow.test.mjs +++ b/test/unit/simplify/pow.test.mjs @@ -20,7 +20,7 @@ describe('pow: Zero Base', () => { }); test('pow: negative base with integer exponent', () => { - assert.equal(out('pow(-2, 3)'), '-8'); + assert.equal(out('pow(-2, 3)'), 'calc(-8)'); }); test('pow: negative base with non-integer exponent → NaN', () => { diff --git a/test/unit/simplify/product.test.mjs b/test/unit/simplify/product.test.mjs index c9ec2c0..bcf50d3 100644 --- a/test/unit/simplify/product.test.mjs +++ b/test/unit/simplify/product.test.mjs @@ -106,7 +106,7 @@ test('distribute: number × resolvable-sum folds through each term', () => { describe('distribute: Preserves Negative', () => { test('distribute: preserves negative signs across each term', () => { - assert.equal(out('calc(2 * (1em - 3em))'), '-4em'); + assert.equal(out('calc(2 * (1em - 3em))'), 'calc(-4em)'); }); test('distribute: distribute-then-merge same-unit results', () => { @@ -200,10 +200,10 @@ describe('arithmetic: 1 /', () => { test('precision: dim value folds in original left-to-right position, not last', () => { assert.equal( out('calc(-11px * -57 * -80 * -70 / 17 * -19)', { precision: false }), - '-3924282.3529411764px' + 'calc(-3924282.3529411764px)' ); assert.equal( out('calc(-57 * -11px * -80 * -70 / 17 * -19)', { precision: false }), - '-3924282.3529411764px' + 'calc(-3924282.3529411764px)' ); }); diff --git a/test/unit/simplify/round.test.mjs b/test/unit/simplify/round.test.mjs index 0e740d0..b626a40 100644 --- a/test/unit/simplify/round.test.mjs +++ b/test/unit/simplify/round.test.mjs @@ -19,23 +19,23 @@ describe('round: Default Strategy', () => { test('round: tie breaks to upper B (§10.3 line 978)', () => { assert.equal(out('round(15, 10)'), '20'); - assert.equal(out('round(-15, 10)'), '-10'); // upper of {-20, -10} is -10 + assert.equal(out('round(-15, 10)'), 'calc(-10)'); // upper of {-20, -10} is -10 }); test('round: up strategy → ceiling step', () => { assert.equal(out('round(up, 11, 10)'), '20'); assert.equal(out('round(up, 10, 10)'), '10'); // exact multiple - assert.equal(out('round(up, -11, 10)'), '-10'); + assert.equal(out('round(up, -11, 10)'), 'calc(-10)'); }); test('round: down strategy → floor step', () => { assert.equal(out('round(down, 19, 10)'), '10'); - assert.equal(out('round(down, -11, 10)'), '-20'); + assert.equal(out('round(down, -11, 10)'), 'calc(-20)'); }); test('round: to-zero strategy', () => { assert.equal(out('round(to-zero, 19, 10)'), '10'); - assert.equal(out('round(to-zero, -19, 10)'), '-10'); + assert.equal(out('round(to-zero, -19, 10)'), 'calc(-10)'); assert.equal(out('round(to-zero, 1, 10)'), '0'); assert.equal(out('round(to-zero, -1, 10)'), '0'); }); @@ -84,10 +84,10 @@ describe('round: Default Strategy', () => { }); test('round: negative A with each strategy', () => { - assert.equal(out('round(nearest, -7, 5)'), '-5'); // {-10, -5}, |-2| < |-3| - assert.equal(out('round(up, -7, 5)'), '-5'); - assert.equal(out('round(down, -7, 5)'), '-10'); - assert.equal(out('round(to-zero, -7, 5)'), '-5'); + assert.equal(out('round(nearest, -7, 5)'), 'calc(-5)'); // {-10, -5}, |-2| < |-3| + assert.equal(out('round(up, -7, 5)'), 'calc(-5)'); + assert.equal(out('round(down, -7, 5)'), 'calc(-10)'); + assert.equal(out('round(to-zero, -7, 5)'), 'calc(-5)'); }); test('round: negative B is allowed', () => { diff --git a/test/unit/simplify/sign.test.mjs b/test/unit/simplify/sign.test.mjs index 051c78e..5f88757 100644 --- a/test/unit/simplify/sign.test.mjs +++ b/test/unit/simplify/sign.test.mjs @@ -8,7 +8,7 @@ describe('sign: Positive Number', () => { }); test('sign: negative number → -1', () => { - assert.equal(out('sign(-5)'), '-1'); + assert.equal(out('sign(-5)'), 'calc(-1)'); }); test('sign: zero → 0', () => { @@ -20,7 +20,7 @@ describe('sign: Positive Number', () => { }); test('sign: negative dimension → -1', () => { - assert.equal(out('sign(-10px)'), '-1'); + assert.equal(out('sign(-10px)'), 'calc(-1)'); }); test('sign: zero dimension → 0', () => { @@ -37,7 +37,7 @@ describe('sign: Positive Number', () => { }); test('sign: inner sum folds first then sign', () => { - assert.equal(out('sign(calc(1px - 3px))'), '-1'); + assert.equal(out('sign(calc(1px - 3px))'), 'calc(-1)'); }); test('sign: infinity → 1', () => { @@ -45,7 +45,7 @@ describe('sign: Positive Number', () => { }); test('sign: -infinity → -1', () => { - assert.equal(out('sign(calc(0 - infinity))'), '-1'); + assert.equal(out('sign(calc(0 - infinity))'), 'calc(-1)'); }); test('sign: NaN → NaN', () => { diff --git a/test/unit/simplify/trig.test.mjs b/test/unit/simplify/trig.test.mjs index d21a621..5b7a36f 100644 --- a/test/unit/simplify/trig.test.mjs +++ b/test/unit/simplify/trig.test.mjs @@ -48,7 +48,7 @@ describe('sin: 0.5turn →', () => { describe('cos: Pi Keyword', () => { test('cos: pi keyword → -1', () => { - assert.equal(out('cos(pi)'), '-1'); + assert.equal(out('cos(pi)'), 'calc(-1)'); }); test('cos: 60deg → 0.5', () => { diff --git a/types/lib/serialize.d.ts b/types/lib/serialize.d.ts index f3c408f..204dd5c 100644 --- a/types/lib/serialize.d.ts +++ b/types/lib/serialize.d.ts @@ -11,6 +11,10 @@ export type SerializeOptions = { * Wrapper name to use when `calc()` is needed. Default `'calc'`. */ calcName?: string; + /** + * Serialize finite negative scalars without a wrapper. Internal selector-only mode. + */ + unwrapSingleNegativeNumber?: boolean; }; /** * @param {Node} node diff --git a/types/reduce.d.ts b/types/reduce.d.ts index a1304f8..ee2bcd7 100644 --- a/types/reduce.d.ts +++ b/types/reduce.d.ts @@ -2,6 +2,10 @@ import { hasPotentialMathFunction, QUICK_MATH_TEST } from './lib/simplify/call.j export type ReduceCalcOptions = { precision?: number | false; warnWhenCannotResolve?: boolean; + /** + * Serialize finite negative results without a `calc()` wrapper. Defaults to `false`. + */ + unwrapSingleNegativeNumber?: boolean; /** * Invoked when parse/simplify throws. */ @@ -23,7 +27,6 @@ export type Replacement = { end: number; node: import('./lib/node.js').Node; calcName: string; - matchedName: string; }; /** * Simplify every supported CSS math function in a component-value string.