Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
107 changes: 68 additions & 39 deletions test/conformance/corpus.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
);
});
65 changes: 9 additions & 56 deletions test/conformance/csstools.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 ----------------------------------
Expand Down Expand Up @@ -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) ---------
Expand Down
13 changes: 2 additions & 11 deletions test/conformance/wpt.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
91 changes: 91 additions & 0 deletions test/helpers/arbitraries.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down Expand Up @@ -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) }));
Loading