diff --git a/README.md b/README.md index 4108926..37afb36 100755 --- a/README.md +++ b/README.md @@ -186,6 +186,14 @@ npm install npm test ``` +The normal test run uses a deterministic structural sample of the harvested +real-world corpus. Run the complete differential corpus before releases or +when changing parsing/simplification behavior: + +```bash +pnpm test:corpus:full +``` + ## [Changelog](CHANGELOG.md) ## [License](LICENSE) diff --git a/package.json b/package.json index e309c5c..c15cc8a 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,9 @@ "scripts": { "lint": "oxlint . && tsc && oxfmt --check", "fmt": "oxfmt", - "test": "node --test" + "test": "node --test --test-reporter=dot 'test/**/*.test.mjs' test/index.cjs test/convertUnit.cjs", + "test:mutation:corpus": "node test/mutation/corpus-selection.mjs", + "test:corpus:full": "POSTCSS_CALC_FULL_CORPUS=1 node --test test/conformance/corpus.test.mjs" }, "author": "Andy Jansson", "license": "MIT", diff --git a/test/conformance/corpus.test.mjs b/test/conformance/corpus.test.mjs index e169d19..a752bde 100644 --- a/test/conformance/corpus.test.mjs +++ b/test/conformance/corpus.test.mjs @@ -16,6 +16,11 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { calc as csstoolsCalc } from '@csstools/css-calc'; import { out } from '../helpers/out.mjs'; +import { + ROUTINE_CORPUS_TARGET, + selectCorpusExpressions, + stableHash, +} from '../helpers/corpus-selection.mjs'; const CORPUS_DIR = fileURLToPath(new URL('../corpus/', import.meta.url)); const COMPARE_PRECISION = 10; function ourOut(input) { @@ -102,49 +107,73 @@ function runLibrary(lib, calcs) { } return result; } -const libraries = readdirSync(CORPUS_DIR) +const corpusFiles = readdirSync(CORPUS_DIR) .filter((f) => f.endsWith('.txt')) - .sort((a, b) => a.localeCompare(b)); -const results = []; -for (const file of libraries) { - const lib = file.replace(/\.txt$/, ''); - const calcs = readFileSync(join(CORPUS_DIR, file), 'utf8') - .split('\n') - .map((l) => l.trim()) - .filter((l) => l.length > 0); - results.push(runLibrary(lib, calcs)); -} -// --- Per-library tests ----------------------------------------------------- -for (const r of results) { - test(`corpus: ${r.lib} — ${r.total} expressions`, () => { - if (r.divergences.length > 0) { - const sample = r.divergences - .slice(0, 5) - .map( - (d) => - ` input: ${d.input}\n ours: ${d.ours}\n theirs: ${d.theirs}` - ) - .join('\n\n'); - assert.fail( - `${r.divergences.length} / ${r.total} diverge from csstools in ${r.lib} ` + - `(showing first 5):\n\n${sample}` - ); - } - console.log(`${r.agree}/${r.total} agree`); - }); + .sort((a, b) => a.localeCompare(b)) + .map((file) => join(CORPUS_DIR, file)); +// github/expressions.txt is the harvested valid-expression pool. Its sibling +// invalid.txt and preprocessor.txt intentionally stay in their dedicated +// resilience suites rather than being discarded by the sampler. +corpusFiles.push(join(CORPUS_DIR, 'github', 'expressions.txt')); +const allCalcs = []; +for (const file of corpusFiles) { + allCalcs.push( + ...readFileSync(file, 'utf8') + .split('\n') + .map((l) => l.trim()) + .filter((l) => l.length > 0) + ); } -// --- Summary -------------------------------------------------------------- -test('corpus: overall summary', () => { - const total = results.reduce((a, r) => a + r.total, 0); - const agree = results.reduce((a, r) => a + r.agree, 0); - const diverge = results.reduce((a, r) => a + r.divergences.length, 0); - const both = results.reduce((a, r) => a + r.bothFailed, 0); + +const fullCorpus = process.env.POSTCSS_CALC_FULL_CORPUS === '1'; +const selection = selectCorpusExpressions(allCalcs); +const inputs = fullCorpus ? selection.allInputs : selection.routineInputs; +const result = runLibrary(fullCorpus ? 'full' : 'sample', inputs); +const parserRejectedHash = stableHash(selection.parserRejected.join('\n')); +const parserRejectedAcceptedByCsstools = selection.parserRejected.filter( + (input) => theirOut(input) !== null +); + +// These are Sass/preprocessor and malformed inputs harvested by the GitHub +// pool. They are checked separately because css-calc passes them through, +// while this package intentionally rejects them as non-CSS expressions. +const EXPECTED_PARSER_REJECTED_COUNT = 2325; +const EXPECTED_PARSER_REJECTED_HASH = 4011635432; +const EXPECTED_PARSER_REJECTED_ACCEPTED_BY_CSSTOOLS = 2325; + +test(`corpus: ${fullCorpus ? 'full' : 'structural sample'} differential`, () => { + if (!fullCorpus) { + assert.ok( + selection.selected.length >= 5000 && selection.selected.length <= 8000, + `routine corpus sample must stay in the 5,000–8,000 budget; got ${selection.selected.length}` + ); + assert.equal(selection.selected.length, ROUTINE_CORPUS_TARGET); + } + if (result.divergences.length > 0) { + const sample = result.divergences + .slice(0, 5) + .map( + (d) => + ` input: ${d.input}\n ours: ${d.ours}\n theirs: ${d.theirs}` + ) + .join('\n\n'); + assert.fail( + `${result.divergences.length} / ${result.total} diverge from csstools ` + + `(showing first 5):\n\n${sample}` + ); + } console.log( - `\n corpus totals: ${agree}/${total} agree, ${diverge} diverge, ${both} both-failed` + `\n corpus ${fullCorpus ? 'full' : 'sample'}: ${result.agree}/${result.total} agree, ` + + `${result.divergences.length} diverge, ${result.bothFailed} both-failed ` + + `(eligible ${selection.eligible}/${selection.total})` ); +}); + +test('corpus: parser-rejected inputs remain accounted for', () => { + assert.equal(selection.parserRejected.length, EXPECTED_PARSER_REJECTED_COUNT); + assert.equal(parserRejectedHash, EXPECTED_PARSER_REJECTED_HASH); assert.equal( - diverge, - 0, - `${diverge} undocumented divergences across the corpus` + parserRejectedAcceptedByCsstools.length, + EXPECTED_PARSER_REJECTED_ACCEPTED_BY_CSSTOOLS ); }); diff --git a/test/conformance/csstools.test.mjs b/test/conformance/csstools.test.mjs index 598f950..45a065c 100644 --- a/test/conformance/csstools.test.mjs +++ b/test/conformance/csstools.test.mjs @@ -15,27 +15,13 @@ import { out as pipeline } from '../helpers/out.mjs'; /** Full-precision output, matching csstools' default. */ const out = (input) => pipeline(input, { precision: false }); // --- basic/test.mjs ------------------------------------------------------- -test('csstools basic: number multiplication', () => { - assert.equal(out('calc(10 * 2)'), '20'); -}); -test('csstools basic: left-associative division', () => { - assert.equal(out('calc(15 / 5 / 3)'), '1'); -}); -test('csstools basic: parenthesized right-hand division', () => { +// One representative keeps arithmetic precedence and explicit grouping here; +// focused parser/simplifier and grammar properties cover the overlapping +// plain add/subtract/multiply examples. +test('csstools basic: precedence and parenthesized division', () => { assert.equal(out('calc(15 / (5 / 3))'), '9'); -}); -test('csstools basic: precedence in mixed + / *', () => { assert.equal(out('calc(2 * 3 + 7 * 5)'), '41'); }); -test('csstools basic: nested parens honored', () => { - assert.equal(out('calc(((2 * 3) + 7) * 5)'), '65'); -}); -test('csstools basic: simple addition of numbers', () => { - assert.equal(out('calc(2 + 3)'), '5'); -}); -test('csstools basic: simple subtraction of numbers', () => { - assert.equal(out('calc(10 - 4)'), '6'); -}); // --- wpt/calc-unit-analysis.mjs ------------------------------------------ test('csstools unit-analysis: calc(0) → 0', () => { assert.equal(out('calc(0)'), '0'); @@ -70,39 +56,12 @@ test('csstools unit-analysis: number * length folds', () => { test('csstools unit-analysis: length * length preserved (unit^2 not expressible)', () => { assert.equal(out('calc(2px * 1px)'), 'calc(2px * 1px)'); }); -// --- wpt/calc-time-values.mjs (same-unit + cross-unit with exact math) --- -test('csstools time: s + s', () => { - assert.equal(out('calc(4s + 1s)'), '5s'); -}); -test('csstools time: ms + ms', () => { - assert.equal(out('calc(4ms + 1ms)'), '5ms'); -}); -test('csstools time: s - s', () => { - assert.equal(out('calc(4s - 1s)'), '3s'); -}); -test('csstools time: number * s', () => { - assert.equal(out('calc(4 * 1s)'), '4s'); -}); -test('csstools time: s * number', () => { - assert.equal(out('calc(1s * 4)'), '4s'); -}); -test('csstools time: s / number', () => { - assert.equal(out('calc(8s / 4)'), '2s'); -}); -test('csstools time: s / s → unitless', () => { +// --- wpt/calc-time-values.mjs -------------------------------------------- +test('csstools time: compatible units divide to a number', () => { assert.equal(out('calc(8s / 2s)'), '4'); }); -// --- wpt/calc-angle-values.mjs (same-unit cases — exact math) ------------ -test('csstools angle: deg + deg', () => { - assert.equal(out('calc(45deg + 45deg)'), '90deg'); -}); -test('csstools angle: rad + rad', () => { - assert.equal(out('calc(45rad + 45rad)'), '90rad'); -}); -test('csstools angle: grad + grad', () => { - assert.equal(out('calc(45grad + 45grad)'), '90grad'); -}); -test('csstools angle: turn + turn', () => { +// --- wpt/calc-angle-values.mjs ------------------------------------------- +test('csstools angle: compatible angle sum', () => { assert.equal(out('calc(0.5turn + 0.5turn)'), '1turn'); }); // --- wpt/minmax-percentage-computed.mjs ---------------------------------- @@ -148,13 +107,7 @@ test('csstools max-20: max with many numeric args folds', () => { ); }); // --- wpt/calc-in-calc.mjs ------------------------------------------------ -test('csstools calc-in-calc: nested calc flattens', () => { - assert.equal(out('calc(calc(1))'), '1'); -}); -test('csstools calc-in-calc: double-nested calc flattens', () => { - assert.equal(out('calc(calc(calc(2px)))'), '2px'); -}); -test('csstools calc-in-calc: nested calc with sum', () => { +test('csstools calc-in-calc: nested calculation flattens', () => { assert.equal(out('calc(calc(1px + 2px))'), '3px'); }); // --- wpt/clamp-length-computed.mjs (same-unit, fully-resolvable) --------- diff --git a/test/conformance/wpt.test.mjs b/test/conformance/wpt.test.mjs index 263af4b..7f96e15 100644 --- a/test/conformance/wpt.test.mjs +++ b/test/conformance/wpt.test.mjs @@ -47,17 +47,8 @@ test('WPT minmax-length: max folds when all args share a unit', () => { // WPT (same unit): `max(1px, 2px, 3px)` → `3px`. assert.equal(out('max(1px, 2px, 3px)'), '3px'); }); -// --- calc-in-calc.html --------------------------------------------------- -// https://github.com/web-platform-tests/wpt/blob/master/css/css-values/calc-in-calc.html -test('WPT calc-in-calc: outer calc() flattens inner calc()', () => { - assert.equal(out('calc(calc(100%))'), '100%'); -}); -test('WPT calc-in-calc: nested calc() with sum', () => { - assert.equal(out('calc(calc(1px + 2px) + 3px)'), '6px'); -}); -test('WPT calc-in-calc: doubly-nested calc', () => { - assert.equal(out('calc(calc(calc(5px)))'), '5px'); -}); +// calc-in-calc flattening is represented once in csstools.test.mjs; the +// source grammar property also generates nested calc() wrappers. // --- calc-catch-divide-by-0.html (now §10.9.1 IEEE-754 form) ------------ // https://github.com/web-platform-tests/wpt/blob/master/css/css-values/calc-catch-divide-by-0.html test('WPT divide-by-zero: 100px / 0 → calc(infinity * 1px)', () => { diff --git a/test/helpers/arbitraries.mjs b/test/helpers/arbitraries.mjs index 7663d8a..05b4397 100644 --- a/test/helpers/arbitraries.mjs +++ b/test/helpers/arbitraries.mjs @@ -3,6 +3,8 @@ import fc from 'fast-check'; import { mkSum, mkProduct } from '../../src/lib/node.js'; import { serialize } from '../../src/lib/serialize.js'; +import { tokenize } from '../../src/lib/tokenizer.js'; +import { parse } from '../../src/lib/parser.js'; const KNOWN_UNITS = ['px', 'em', 'rem', 'vw', 's', 'ms', 'deg', 'turn', '%']; const numLeaf = fc .integer({ min: -100, max: 100 }) @@ -292,3 +294,92 @@ export function astToCalc(ast) { const inner = serialize(ast, { precision: false }); return inner.startsWith('calc(') ? inner : `calc(${inner})`; } + +// CSS Values & Units Level 4 math grammar, bounded to the functions exposed +// by isSupportedMathFunction in this package. This intentionally generates +// source text, then sends it through the production tokenizer/parser: parser +// grouping markers are observable behavior and must not be manufactured with +// raw AST object literals in a test generator. +const CSS_MATH_UNITS = ['px', 'em', 's', 'deg', '%']; +const cssNumberArb = fc.integer({ min: -20, max: 20 }).map(String); +const cssDimensionArb = fc + .tuple(fc.integer({ min: -20, max: 20 }), fc.constantFrom(...CSS_MATH_UNITS)) + .map(([value, unit]) => `${value}${unit}`); +const opaqueLeafArb = fc.constantFrom( + 'var(--a)', + 'var(--b, 2px)', + 'var(--c, calc(1px + 2px))', + 'unknown-math(var(--d))' +); + +function checkedSource(source) { + // Keep the generator tied to the production grammar. Throwing here makes a + // generator update fail immediately instead of silently reducing coverage. + parse(tokenize(source)); + return source; +} + +const cssMathInnerArb = fc.memo((depth) => { + const leaf = fc.oneof(cssNumberArb, cssDimensionArb, opaqueLeafArb); + if (depth <= 1) return leaf; + const smaller = cssMathInnerArb(depth - 1); + const binary = fc.oneof( + fc.tuple(smaller, smaller).map(([a, b]) => `${a} + ${b}`), + fc.tuple(smaller, smaller).map(([a, b]) => `${a} - (${b})`), + fc.tuple(smaller, smaller).map(([a, b]) => `${a} * ${b}`), + fc.tuple(smaller, smaller).map(([a, b]) => `(${a} + ${b})`) + ); + const calls = fc.oneof( + fc + .tuple(fc.constantFrom('min', 'max', 'hypot'), smaller, smaller) + .map(([name, a, b]) => `${name}(${a}, ${b})`), + fc + .tuple( + fc.constantFrom('abs', 'sign', 'sin', 'cos', 'tan', 'sqrt', 'exp'), + smaller + ) + .map(([name, a]) => `${name}(${a})`), + fc + .tuple(fc.constantFrom('pow', 'atan2', 'mod', 'rem'), smaller, smaller) + .map(([name, a, b]) => `${name}(${a}, ${b})`), + fc + .tuple(smaller, smaller, smaller) + .map(([a, b, c]) => `clamp(${a}, ${b}, ${c})`), + fc.tuple(smaller, smaller).map(([a, b]) => `round(${a}, ${b})`), + fc.tuple(smaller).map(([a]) => `calc(${a})`) + ); + return fc.oneof({ weight: 2, arbitrary: leaf }, binary, calls); +}); + +/** Source-level, parser-checked CSS math expressions for grammar properties. */ +export const cssMathSourceArb = cssMathInnerArb(3).map((inner) => + checkedSource(`calc(${inner})`) +); + +/** + * Opaque grouped sums with exact expected output. Negative signs must remain + * outside the group; positive groups can flatten because no sign distributes. + */ +export const opaqueGroupedCalcArb = fc + .tuple(fc.constantFrom('--a', '--x'), fc.constantFrom('--b', '--y')) + .chain(([a, b]) => + fc.constantFrom( + { + input: `calc(-(var(${a}) + var(${b})))`, + expected: `calc(-(var(${a}) + var(${b})))`, + }, + { + input: `calc(var(${a}) - (var(${b}) + 10px))`, + expected: `calc(var(${a}) - (10px + var(${b})))`, + }, + { + input: `calc(5px - (var(${a}, 1px) + var(${b}, calc(2px + 3px))))`, + expected: `calc(5px - (var(${a}, 1px) + var(${b}, 5px)))`, + }, + { + input: `calc(var(${a}) + (var(${b}) + 10px))`, + expected: `calc(10px + var(${a}) + var(${b}))`, + } + ) + ) + .map((example) => ({ ...example, input: checkedSource(example.input) })); diff --git a/test/helpers/corpus-selection.mjs b/test/helpers/corpus-selection.mjs new file mode 100644 index 0000000..1f6dca1 --- /dev/null +++ b/test/helpers/corpus-selection.mjs @@ -0,0 +1,170 @@ +// Deterministic structural sampling for the harvested real-world corpus. +// +// The corpus is intentionally much larger than a routine test run needs. +// This helper keeps the full input list available to deep validation while +// choosing a stable, shape-diverse routine sample. It derives shape from the +// real parser rather than from source spelling so whitespace and literal +// churn do not crowd out distinct calculation forms. +import { parse } from '../../src/lib/parser.js'; +import { tokenize } from '../../src/lib/tokenizer.js'; +import { baseOf, convert } from '../../src/lib/convertUnits.js'; +import { isSupportedMathFunction } from '../../src/lib/simplify/call.js'; + +export const ROUTINE_CORPUS_TARGET = 6000; + +/** A stable 32-bit hash; never use input order as a tie-breaker. */ +export function stableHash(value) { + let hash = 2166136261; + for (let i = 0; i < value.length; i++) { + hash = Math.imul(hash, 16777619); + // Math.imul returns a signed 32-bit result; normalize it before adding + // the code point so every update remains an exact unsigned 32-bit value. + if (hash < 0) hash += 4294967296; + hash += value.charCodeAt(i); + if (hash >= 4294967296) hash -= 4294967296; + } + return hash; +} + +function unitClass(unit) { + const base = baseOf(unit) ?? 'unknown'; + const canonicalUnit = { + length: 'px', + angle: 'deg', + time: 's', + frequency: 'hz', + resolution: 'dppx', + }[base]; + const exact = canonicalUnit && convert(1, unit, canonicalUnit) !== null; + return `${unit}:${base}:${exact ? 'exact' : 'contextual'}`; +} + +function numberBucket(value) { + const zero = value === 0 ? 'zero' : 'nonzero'; + let sign = 'zero'; + if (value < 0) sign = 'negative'; + if (value > 0) sign = 'positive'; + return `${zero}:${sign}:${Number.isInteger(value) ? 'integer' : 'fraction'}`; +} + +/** + * @param {import('../../src/lib/node.js').Node} node + * @param {string[]} literals + * @return {string} + */ +function describe(node, literals) { + switch (node.type) { + case 'Num': + literals.push(`number:${numberBucket(node.value)}`); + return 'Num'; + case 'Dim': + literals.push( + `dimension:${unitClass(node.unit)}:${numberBucket(node.value)}` + ); + return `Dim(${unitClass(node.unit)})`; + case 'Ident': + return 'Ident(opaque)'; + case 'Call': { + const opaque = + node.name.toLowerCase() === 'var' || + !isSupportedMathFunction(node.name); + return `Call(${node.name.toLowerCase()}:${node.args.length}:${opaque ? 'opaque' : 'foldable'}:[${node.args.map((arg) => describe(arg, literals)).join(',')}])`; + } + case 'Sum': + return `Sum(${node.grouped ? 'grouped' : 'flat'}:[${node.terms.map((term) => `${term.sign}:${describe(term.node, literals)}`).join(',')}])`; + case 'Product': + return `Product([${node.factors.map((factor) => `${factor.exponent}:${describe(factor.node, literals)}`).join(',')}])`; + } +} + +/** + * Parse an expression and return its structural signature and literal bucket. + * `null` means the expression could not be classified structurally. Such + * inputs must still be retained by the differential corpus: css-calc may + * accept an input that our parser rejects. + */ +export function classifyCorpusExpression(input) { + try { + const literals = []; + const ast = parse(tokenize(input)); + // A calc wrapper 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 ( + ast.type === 'Call' && + ['calc', '-webkit-calc', '-moz-calc'].includes(ast.name.toLowerCase()) && + ast.args.length !== 1 + ) { + return null; + } + const signature = describe(ast, literals); + return { signature, literalBucket: literals.join('|') || 'no-literals' }; + } catch { + return null; + } +} + +/** + * Keep one hash-selected representative of every parser shape, plus one more + * when a different literal compatibility bucket exists. A stable globally + * ranked fill reaches the routine budget without making source-file ordering + * part of the selection. + */ +export function selectCorpusExpressions( + inputs, + target = ROUTINE_CORPUS_TARGET +) { + const eligible = []; + const parserRejected = []; + for (const input of new Set(inputs)) { + const classification = classifyCorpusExpression(input); + if (classification) { + eligible.push({ input, ...classification, hash: stableHash(input) }); + } else { + parserRejected.push(input); + } + } + + const bySignature = new Map(); + for (const item of eligible) { + const items = bySignature.get(item.signature) ?? []; + items.push(item); + bySignature.set(item.signature, items); + } + + const selected = new Set(); + for (const items of bySignature.values()) { + items.sort((a, b) => a.hash - b.hash || a.input.localeCompare(b.input)); + selected.add(items[0].input); + const firstBucket = items[0].literalBucket; + const second = items.find((item) => item.literalBucket !== firstBucket); + if (second) selected.add(second.input); + } + + if (selected.size < target) { + for (const item of [...eligible].sort( + (a, b) => a.hash - b.hash || a.input.localeCompare(b.input) + )) { + if (selected.size >= target) break; + selected.add(item.input); + } + } + + const sortByHash = (a, b) => { + const hashDiff = stableHash(a) - stableHash(b); + return hashDiff || a.localeCompare(b); + }; + return { + total: inputs.length, + eligible: eligible.length, + parserRejected: parserRejected.sort(sortByHash), + eligibleInputs: eligible + .sort((a, b) => a.hash - b.hash || a.input.localeCompare(b.input)) + .map((item) => item.input), + selected: [...selected].sort(sortByHash), + routineInputs: [...selected].sort(sortByHash), + allInputs: eligible + .sort((a, b) => a.hash - b.hash || a.input.localeCompare(b.input)) + .map((item) => item.input), + }; +} diff --git a/test/mutation/corpus-selection.mjs b/test/mutation/corpus-selection.mjs new file mode 100644 index 0000000..f255de7 --- /dev/null +++ b/test/mutation/corpus-selection.mjs @@ -0,0 +1,42 @@ +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const projectRoot = fileURLToPath(new URL('../..', import.meta.url)); +const registerHooksPath = fileURLToPath( + new URL('./register-hooks.mjs', import.meta.url) +); +const mutations = [ + { + name: 'drops parser-rejected inputs', + file: 'test/helpers/corpus-selection.mjs', + find: 'parserRejected: parserRejected.sort(sortByHash),', + replace: 'parserRejected: [],', + }, + { + name: 'stops comparing parser-rejected inputs with css-calc', + file: 'test/conformance/corpus.test.mjs', + find: '(input) => theirOut(input) !== null', + replace: '(input) => false', + }, +]; + +for (const mutation of mutations) { + const result = spawnSync( + process.execPath, + [ + '--import', + registerHooksPath, + '--test', + 'test/conformance/corpus.test.mjs', + ], + { + cwd: projectRoot, + encoding: 'utf8', + env: { ...process.env, POSTCSS_CALC_MUTATION: JSON.stringify(mutation) }, + } + ); + if (result.status === 0) { + throw new Error(`Surviving mutation: ${mutation.name}`); + } + process.stdout.write(`killed: ${mutation.name}\n`); +} diff --git a/test/mutation/register-hooks.mjs b/test/mutation/register-hooks.mjs new file mode 100644 index 0000000..473d045 --- /dev/null +++ b/test/mutation/register-hooks.mjs @@ -0,0 +1,21 @@ +import { registerHooks } from 'node:module'; +import { join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const projectRoot = fileURLToPath(new URL('../..', import.meta.url)); +const mutation = JSON.parse(process.env.POSTCSS_CALC_MUTATION); +const targetUrl = pathToFileURL(join(projectRoot, mutation.file)).href; + +registerHooks({ + load(url, context, nextLoad) { + const result = nextLoad(url, context); + if (url !== targetUrl || result.format !== 'module') return result; + + const source = String(result.source); + const mutated = source.replace(mutation.find, mutation.replace); + if (mutated === source) { + throw new Error(`Mutation did not match: ${mutation.name}`); + } + return { ...result, source: mutated }; + }, +}); diff --git a/test/property/opaque-grouping.test.mjs b/test/property/opaque-grouping.test.mjs new file mode 100644 index 0000000..dc73396 --- /dev/null +++ b/test/property/opaque-grouping.test.mjs @@ -0,0 +1,45 @@ +// Source-grammar properties for unresolved CSS expressions. Differential +// testing cannot be the oracle here: preserving parentheses around opaque +// sums is our explicit semantic contract. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fc from 'fast-check'; +import { tokenize } from '../../src/lib/tokenizer.js'; +import { parse } from '../../src/lib/parser.js'; +import { simplify } from '../../src/lib/simplify.js'; +import { serialize } from '../../src/lib/serialize.js'; +import { out } from '../helpers/out.mjs'; +import { + cssMathSourceArb, + opaqueGroupedCalcArb, +} from '../helpers/arbitraries.mjs'; + +test('property: bounded CSS math grammar parses and round-trips', () => { + fc.assert( + fc.property(cssMathSourceArb, (input) => { + const output = serialize(simplify(parse(tokenize(input)))); + return typeof output === 'string' && parse(tokenize(output)) !== null; + }), + { numRuns: 300 } + ); +}); + +test('property: opaque grouped sums never distribute a negative sign', () => { + fc.assert( + fc.property(opaqueGroupedCalcArb, ({ input, expected }) => { + assert.equal(out(input), expected); + }), + { numRuns: 100 } + ); +}); + +test('opaque grouping: nested groups and var() fallbacks preserve serialization', () => { + assert.equal( + out('calc(var(--a) - (var(--b) - (var(--c) + var(--d))))'), + 'calc(var(--a) - (var(--b) - (var(--c) + var(--d))))' + ); + assert.equal( + out('calc(-(var(--a, calc(1px + 2px)) + var(--b, 4px)))'), + 'calc(-(var(--a, 3px) + var(--b, 4px)))' + ); +}); diff --git a/test/unit/corpus-selection.test.mjs b/test/unit/corpus-selection.test.mjs new file mode 100644 index 0000000..aa272ad --- /dev/null +++ b/test/unit/corpus-selection.test.mjs @@ -0,0 +1,49 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + ROUTINE_CORPUS_TARGET, + classifyCorpusExpression, + selectCorpusExpressions, + stableHash, +} from '../helpers/corpus-selection.mjs'; + +const FIXTURE = [ + 'calc(var(--a) - (var(--b) + var(--c)))', + 'calc(-(var(--a) + var(--b)))', + 'calc(1px + 2px)', + 'calc(0px + 2px)', + 'calc(1.5px + 2px)', + 'calc(1deg + 2deg)', + 'calc(1px +)', +]; + +test('corpus selection: is deterministic and retains grouped subtraction shapes', () => { + const first = selectCorpusExpressions(FIXTURE, 20); + const second = selectCorpusExpressions([...FIXTURE].reverse(), 20); + assert.deepEqual(first, second); + assert.equal(first.total, FIXTURE.length); + assert.equal(first.eligible, FIXTURE.length - 1); + assert.ok(first.selected.includes(FIXTURE[0])); + assert.ok(first.selected.includes(FIXTURE[1])); + assert.deepEqual(first.parserRejected, ['calc(1px +)']); + assert.ok(!first.routineInputs.includes('calc(1px +)')); + assert.ok(!first.allInputs.includes('calc(1px +)')); +}); + +test('corpus selection: structural and literal buckets distinguish boundaries', () => { + const integer = classifyCorpusExpression('calc(1px + 2px)'); + const fraction = classifyCorpusExpression('calc(1.5px + 2px)'); + const angle = classifyCorpusExpression('calc(1deg + 2deg)'); + assert.equal(integer?.signature, fraction?.signature); + assert.notEqual(integer?.literalBucket, fraction?.literalBucket); + assert.notEqual(integer?.signature, angle?.signature); + assert.equal(classifyCorpusExpression('calc(1px +)'), null); +}); + +test('corpus selection: hashes retain 32-bit multiplication precision', () => { + assert.notEqual(stableHash('calc(50%)'), stableHash('calc(60%)')); +}); + +test('corpus selection: routine target is a bounded CI budget', () => { + assert.ok(ROUTINE_CORPUS_TARGET >= 5000 && ROUTINE_CORPUS_TARGET <= 8000); +}); diff --git a/test/unit/plugin.test.mjs b/test/unit/plugin.test.mjs index 6bc9e43..2a2c5e5 100644 --- a/test/unit/plugin.test.mjs +++ b/test/unit/plugin.test.mjs @@ -40,6 +40,15 @@ test('plugin: preserves grouping for opaque subtraction', async () => { 'a{a:calc(5px - (var(--var-1) + var(--var-2)));b:calc(var(--a) - (var(--b) + var(--c)));c:calc(var(--a) - (var(--b) - var(--c)));d:calc(5px - (10px + var(--a)))}' ); }); +test('plugin: preserves nested opaque grouping and simplifies var fallbacks', async () => { + const { css } = await process( + 'a{b:calc(var(--a) - (var(--b) - (var(--c, calc(1px + 2px)) + var(--d))))}' + ); + assert.equal( + css, + 'a{b:calc(var(--a) - (var(--b) - (var(--c, 3px) + var(--d))))}' + ); +}); test('plugin: vendor-prefix calcs get the same simplification', async () => { const { css } = await process('a{b:-webkit-calc(1px + 2px)}'); // Round-trip preserves the prefix via serialize's calcName option.